From 1c17bcc981fda5bc184579705261e0227cf3bd53 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Mon, 13 Feb 2023 20:00:56 +0100 Subject: [PATCH 01/74] Add the Build Manuscript tool and connect it to the main menu --- novelwriter/gui/mainmenu.py | 4 ++-- novelwriter/guimain.py | 21 +++++++++++++++++ novelwriter/tools/mbuild.py | 45 +++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 novelwriter/tools/mbuild.py diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py index 0da2d783..dfde2bee 100644 --- a/novelwriter/gui/mainmenu.py +++ b/novelwriter/gui/mainmenu.py @@ -830,9 +830,9 @@ class GuiMainMenu(QMenuBar): self.toolsMenu.addAction(self.aBackupProject) # Tools > Export Project - self.aBuildProject = QAction(self.tr("Build Novel Project"), self) + self.aBuildProject = QAction(self.tr("Build Manuscript"), self) self.aBuildProject.setShortcut("F5") - self.aBuildProject.triggered.connect(lambda: self.mainGui.showBuildProjectDialog()) + self.aBuildProject.triggered.connect(self.mainGui.showBuildManuscriptDialog) self.toolsMenu.addAction(self.aBuildProject) # Tools > Writing Stats diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index a7f03cb7..59b9a3c8 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -57,6 +57,7 @@ from novelwriter.dialogs.projdetails import GuiProjectDetails from novelwriter.dialogs.projsettings import GuiProjectSettings from novelwriter.tools.build import GuiBuildNovel from novelwriter.tools.lipsum import GuiLipsum +from novelwriter.tools.mbuild import GuiBuildManuscript from novelwriter.tools.projwizard import GuiProjectWizard from novelwriter.tools.writingstats import GuiWritingStats from novelwriter.core.project import NWProject @@ -1009,6 +1010,26 @@ class GuiMain(QMainWindow): return True + @pyqtSlot() + def showBuildManuscriptDialog(self): + """Open the build manuscript dialog. + """ + if not self.hasProject: + logger.error("No project open") + return False + + dlgBuild = getGuiItem("GuiBuildManuscript") + if dlgBuild is None: + dlgBuild = GuiBuildManuscript(self) + assert isinstance(dlgBuild, GuiBuildManuscript) + + dlgBuild.setModal(False) + dlgBuild.show() + dlgBuild.raise_() + qApp.processEvents() + + return True + def showLoremIpsumDialog(self): """Open the insert lorem ipsum text dialog. """ diff --git a/novelwriter/tools/mbuild.py b/novelwriter/tools/mbuild.py new file mode 100644 index 00000000..4b2b6f09 --- /dev/null +++ b/novelwriter/tools/mbuild.py @@ -0,0 +1,45 @@ +""" +novelWriter – GUI Build Manuscript +================================== +GUI classes for the Manuscript build tool + +File History: +Created: 2023-02-13 [2.1-a0] + +This file is a part of novelWriter +Copyright 2018–2023, 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 . +""" + +import logging +import novelwriter + +from PyQt5.QtWidgets import QDialog + +logger = logging.getLogger(__name__) + + +class GuiBuildManuscript(QDialog): + + def __init__(self, mainGui): + super().__init__(parent=mainGui) + + self.mainConf = novelwriter.CONFIG + self.mainGui = mainGui + self.theProject = mainGui.theProject + + return + +# END Class GuiBuildManuscript From 06407a44d312328f0e88b86b4456049eb97efd16 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Mon, 13 Feb 2023 21:34:40 +0100 Subject: [PATCH 02/74] Add tabs to manuscript build tool --- novelwriter/custom.py | 2 +- novelwriter/tools/mbuild.py | 52 ++++++++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/novelwriter/custom.py b/novelwriter/custom.py index 46bd081d..8c766733 100644 --- a/novelwriter/custom.py +++ b/novelwriter/custom.py @@ -382,7 +382,7 @@ class PagedDialog(QDialog): self._tabBar = VerticalTabBar(self) self._tabBar.setExpanding(False) - self._tabBox = QTabWidget() + self._tabBox = QTabWidget(self) self._tabBox.setTabBar(self._tabBar) self._tabBox.setTabPosition(QTabWidget.West) diff --git a/novelwriter/tools/mbuild.py b/novelwriter/tools/mbuild.py index 4b2b6f09..22c9af2f 100644 --- a/novelwriter/tools/mbuild.py +++ b/novelwriter/tools/mbuild.py @@ -26,7 +26,9 @@ along with this program. If not, see . import logging import novelwriter -from PyQt5.QtWidgets import QDialog +from PyQt5.QtWidgets import QDialog, QHBoxLayout, QTabWidget, QWidget + +from novelwriter.custom import VerticalTabBar logger = logging.getLogger(__name__) @@ -40,6 +42,54 @@ class GuiBuildManuscript(QDialog): self.mainGui = mainGui self.theProject = mainGui.theProject + self.setWindowTitle(self.tr("Build Manuscript")) + self.setMinimumWidth(self.mainConf.pxInt(700)) + self.setMinimumHeight(self.mainConf.pxInt(600)) + + # Optiuons Area + # ============= + + self.optTabBar = VerticalTabBar(self) + self.optTabBar.setExpanding(False) + + self.optTabBox = QTabWidget(self) + self.optTabBox.setTabBar(self.optTabBar) + self.optTabBox.setTabPosition(QTabWidget.West) + + # Create Tabs + self.optTabSelect = GuiBuildSelectionTab(self) + self.optTabFormat = GuiBuildFormattingTab(self) + + # Add Tabs + self.optTabBox.addTab(self.optTabSelect, self.tr("Selection")) + self.optTabBox.addTab(self.optTabFormat, self.tr("Formatting")) + + # Assemble + self.outerBox = QHBoxLayout() + self.outerBox.addWidget(self.optTabBox) + + self.setLayout(self.outerBox) + return # END Class GuiBuildManuscript + + +class GuiBuildSelectionTab(QWidget): + + def __init__(self, buildMain): + super().__init__(parent=buildMain) + + return + +# END Class GuiBuildSelectionTab + + +class GuiBuildFormattingTab(QWidget): + + def __init__(self, buildMain): + super().__init__(parent=buildMain) + + return + +# END Class GuiBuildFormattingTab From 7a60d8d8670e47fd13dad6d4f0c5804e6a28078f Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 21 Feb 2023 16:06:41 +0100 Subject: [PATCH 03/74] Add a custom widgets subpackage --- novelwriter/custom.py | 475 ---------------------------- novelwriter/custom/__init__.py | 3 + novelwriter/custom/configlayout.py | 214 +++++++++++++ novelwriter/custom/pageddialog.py | 136 ++++++++ novelwriter/custom/pagedsidebar.py | 62 ++++ novelwriter/custom/switch.py | 176 +++++++++++ novelwriter/dialogs/docmerge.py | 7 +- novelwriter/dialogs/docsplit.py | 11 +- novelwriter/dialogs/preferences.py | 70 ++-- novelwriter/dialogs/projdetails.py | 7 +- novelwriter/dialogs/projsettings.py | 10 +- novelwriter/gui/sidebar.py | 2 +- novelwriter/tools/build.py | 30 +- novelwriter/tools/lipsum.py | 4 +- novelwriter/tools/projwizard.py | 10 +- novelwriter/tools/writingstats.py | 14 +- 16 files changed, 677 insertions(+), 554 deletions(-) delete mode 100644 novelwriter/custom.py create mode 100644 novelwriter/custom/__init__.py create mode 100644 novelwriter/custom/configlayout.py create mode 100644 novelwriter/custom/pageddialog.py create mode 100644 novelwriter/custom/pagedsidebar.py create mode 100644 novelwriter/custom/switch.py diff --git a/novelwriter/custom.py b/novelwriter/custom.py deleted file mode 100644 index 8c766733..00000000 --- a/novelwriter/custom.py +++ /dev/null @@ -1,475 +0,0 @@ -""" -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 2018–2023, 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 . -""" - -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("%s" % 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): - super().__init__(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, offset): - self._offset = offset - self.update() - return - - ## - # Getters and Setters - ## - - def setChecked(self, checked): - """Overload setChecked to also alter the offset. - """ - super().setChecked(checked) - if checked: - self.offset = self._xW - self._xR - else: - self.offset = self._xR - return - - ## - # Events - ## - - def resizeEvent(self, event): - """Overload resize to ensure correct offset. - """ - super().resizeEvent(event) - 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, parent=None): - super().__init__(parent=parent) - - self._tabBar = VerticalTabBar(self) - self._tabBar.setExpanding(False) - - self._tabBox = QTabWidget(self) - 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, widget, label): - """Forward the adding of tabs to the QTabWidget. - """ - self._tabBox.addTab(widget, label) - return - - def addControls(self, buttonBar): - """Add a button bar to the dialog. - """ - self._buttonBox.addWidget(buttonBar) - return - - def setCurrentWidget(self, widget): - """Forward the changing of tab to the QTabWidget. - """ - self._tabBox.setCurrentWidget(widget) - return - -# END Class PagedDialog - - -class VerticalTabBar(QTabBar): - - def __init__(self, parent=None): - super().__init__(parent=parent) - self._mW = novelwriter.CONFIG.pxInt(150) - return - - def tabSizeHint(self, index): - """Return a transposed size hint for the rotated bar. - """ - tSize = super().tabSizeHint(index) - tSize.transpose() - tSize.setWidth(min(tSize.width(), self._mW)) - return tSize - - def paintEvent(self, event): - """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 diff --git a/novelwriter/custom/__init__.py b/novelwriter/custom/__init__.py new file mode 100644 index 00000000..6e4c5a4e --- /dev/null +++ b/novelwriter/custom/__init__.py @@ -0,0 +1,3 @@ +""" +novelWriter – Custom Layouts and Widgets +""" diff --git a/novelwriter/custom/configlayout.py b/novelwriter/custom/configlayout.py new file mode 100644 index 00000000..8eadae62 --- /dev/null +++ b/novelwriter/custom/configlayout.py @@ -0,0 +1,214 @@ +""" +novelWriter – Custom Widget: Config Layout +========================================== +A custom grid layout for config pages + +File History: +Created: 2020-05-03 [0.4.5] + +This file is a part of novelWriter +Copyright 2018–2023, 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 . +""" + +import logging +import novelwriter + +from PyQt5.QtGui import QColor, QPalette +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import ( + QGridLayout, QHBoxLayout, QLabel, QLineEdit, QSizePolicy, QVBoxLayout, + QWidget +) + +logger = logging.getLogger(__name__) + + +class NConfigLayout(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("%s" % 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 = NHelpLabel(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 NConfigLayout + + +class NHelpLabel(QLabel): + + def __init__(self, theText, textCol, fontSize=0.9): + super().__init__(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 NHelpLabel diff --git a/novelwriter/custom/pageddialog.py b/novelwriter/custom/pageddialog.py new file mode 100644 index 00000000..47f957fb --- /dev/null +++ b/novelwriter/custom/pageddialog.py @@ -0,0 +1,136 @@ +""" +novelWriter – Custom Widget: Paged Dialog +========================================= +A custom dialog with tabs and a vertical tab bar + +File History: +Created: 2020-05-17 [0.5.1] + +This file is a part of novelWriter +Copyright 2018–2023, 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 . +""" + +import logging +import novelwriter + +from PyQt5.QtCore import QRect, QPoint +from PyQt5.QtWidgets import ( + QDialog, QHBoxLayout, QStyle, QStyleOptionTab, QStylePainter, QTabBar, + QTabWidget, QVBoxLayout +) + +logger = logging.getLogger(__name__) + + +class NPagedDialog(QDialog): + + def __init__(self, parent=None): + super().__init__(parent=parent) + + self._tabBar = NVerticalTabBar(self) + self._tabBar.setExpanding(False) + + self._tabBox = QTabWidget(self) + 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, widget, label): + """Forward the adding of tabs to the QTabWidget. + """ + self._tabBox.addTab(widget, label) + return + + def addControls(self, buttonBar): + """Add a button bar to the dialog. + """ + self._buttonBox.addWidget(buttonBar) + return + + def setCurrentWidget(self, widget): + """Forward the changing of tab to the QTabWidget. + """ + self._tabBox.setCurrentWidget(widget) + return + +# END Class NPagedDialog + + +class NVerticalTabBar(QTabBar): + + def __init__(self, parent=None): + super().__init__(parent=parent) + self._mW = novelwriter.CONFIG.pxInt(150) + return + + def tabSizeHint(self, index): + """Return a transposed size hint for the rotated bar. + """ + tSize = super().tabSizeHint(index) + tSize.transpose() + tSize.setWidth(min(tSize.width(), self._mW)) + return tSize + + def paintEvent(self, event): + """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 NVerticalTabBar diff --git a/novelwriter/custom/pagedsidebar.py b/novelwriter/custom/pagedsidebar.py new file mode 100644 index 00000000..485d71a7 --- /dev/null +++ b/novelwriter/custom/pagedsidebar.py @@ -0,0 +1,62 @@ +""" +novelWriter – Custom Widget: Paged SideBar +========================================== +A custom widget for making a sidebar for flipping through pages + +File History: +Created: 2023-02-21 [2.1b1] NPagedSideBar +Created: 2023-02-21 [2.1b1] NToolLabelButton + +This file is a part of novelWriter +Copyright 2018–2023, 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 . +""" + +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QToolBar, QToolButton + + +class NPagedSideBar(QToolBar): + + def __init__(self, parent): + super().__init__(parent=parent) + + self._buttons = [] + + self.setMovable(False) + self.setOrientation(Qt.Vertical) + + return + + def addLabel(self, text): + """Add a new label to the toolbar. + """ + return + + def addButton(self, text): + """Add a new button to the toolbar. + """ + return + +# END Class NPagedSideBar + + +class NToolLabelButton(QToolButton): + + def __init__(self, parent): + super().__init__(parent=parent) + return + +# END Class NToolLabelButton diff --git a/novelwriter/custom/switch.py b/novelwriter/custom/switch.py new file mode 100644 index 00000000..d57f07b2 --- /dev/null +++ b/novelwriter/custom/switch.py @@ -0,0 +1,176 @@ +""" +novelWriter – Custom Widget: Switch +=================================== +A custom switch widget + +File History: +Created: 2020-05-03 [0.4.5] + +This file is a part of novelWriter +Copyright 2018–2023, 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 . +""" + +import logging +import novelwriter + +from PyQt5.QtGui import QPainter +from PyQt5.QtCore import Qt, QRectF, QPropertyAnimation, pyqtProperty +from PyQt5.QtWidgets import QSizePolicy, QAbstractButton + +from novelwriter.constants import nwUnicode + +logger = logging.getLogger(__name__) + + +class NSwitch(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, offset): + self._offset = offset + self.update() + return + + ## + # Getters and Setters + ## + + def setChecked(self, checked): + """Overload setChecked to also alter the offset. + """ + super().setChecked(checked) + if checked: + self.offset = self._xW - self._xR + else: + self.offset = self._xR + return + + ## + # Events + ## + + def resizeEvent(self, event): + """Overload resize to ensure correct offset. + """ + super().resizeEvent(event) + 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 NSwitch diff --git a/novelwriter/dialogs/docmerge.py b/novelwriter/dialogs/docmerge.py index 25c8676f..30e42345 100644 --- a/novelwriter/dialogs/docmerge.py +++ b/novelwriter/dialogs/docmerge.py @@ -33,7 +33,8 @@ from PyQt5.QtWidgets import ( QListWidget, QListWidgetItem, QVBoxLayout, ) -from novelwriter.custom import QHelpLabel, QSwitch +from novelwriter.custom.switch import NSwitch +from novelwriter.custom.configlayout import NHelpLabel logger = logging.getLogger(__name__) @@ -56,7 +57,7 @@ class GuiDocMerge(QDialog): self.setWindowTitle(self.tr("Merge Documents")) self.headLabel = QLabel("{0}".format(self.tr("Documents to Merge"))) - self.helpLabel = QHelpLabel(self.tr( + self.helpLabel = NHelpLabel(self.tr( "Drag and drop items to change the order, or uncheck to exclude." ), self.mainTheme.helpText) @@ -75,7 +76,7 @@ class GuiDocMerge(QDialog): # Merge Options self.trashLabel = QLabel(self.tr("Move merged items to Trash")) - self.trashSwitch = QSwitch(width=2*iPx, height=iPx) + self.trashSwitch = NSwitch(width=2*iPx, height=iPx) self.optBox = QGridLayout() self.optBox.addWidget(self.trashLabel, 0, 0) diff --git a/novelwriter/dialogs/docsplit.py b/novelwriter/dialogs/docsplit.py index 0db6d6c2..6e534390 100644 --- a/novelwriter/dialogs/docsplit.py +++ b/novelwriter/dialogs/docsplit.py @@ -33,7 +33,8 @@ from PyQt5.QtWidgets import ( QListWidgetItem, QDialogButtonBox, QLabel, QGridLayout ) -from novelwriter.custom import QHelpLabel, QSwitch +from novelwriter.custom.switch import NSwitch +from novelwriter.custom.configlayout import NHelpLabel logger = logging.getLogger(__name__) @@ -61,7 +62,7 @@ class GuiDocSplit(QDialog): self.setWindowTitle(self.tr("Split Document")) self.headLabel = QLabel("{0}".format(self.tr("Document Headers"))) - self.helpLabel = QHelpLabel( + self.helpLabel = NHelpLabel( self.tr("Select the maximum level to split into files."), self.mainGui.mainTheme.helpText ) @@ -95,15 +96,15 @@ class GuiDocSplit(QDialog): # Split Options self.folderLabel = QLabel(self.tr("Split into a new folder")) - self.folderSwitch = QSwitch(width=2*iPx, height=iPx) + self.folderSwitch = NSwitch(width=2*iPx, height=iPx) self.folderSwitch.setChecked(intoFolder) self.hierarchyLabel = QLabel(self.tr("Create document hierarchy")) - self.hierarchySwitch = QSwitch(width=2*iPx, height=iPx) + self.hierarchySwitch = NSwitch(width=2*iPx, height=iPx) self.hierarchySwitch.setChecked(docHierarchy) self.trashLabel = QLabel(self.tr("Move split document to Trash")) - self.trashSwitch = QSwitch(width=2*iPx, height=iPx) + self.trashSwitch = NSwitch(width=2*iPx, height=iPx) self.optBox = QGridLayout() self.optBox.addWidget(self.folderLabel, 0, 0) diff --git a/novelwriter/dialogs/preferences.py b/novelwriter/dialogs/preferences.py index 41f4a032..fff952ad 100644 --- a/novelwriter/dialogs/preferences.py +++ b/novelwriter/dialogs/preferences.py @@ -33,13 +33,15 @@ from PyQt5.QtWidgets import ( QLineEdit, QFileDialog, QFontDialog, QDoubleSpinBox ) -from novelwriter.custom import QSwitch, QConfigLayout, PagedDialog +from novelwriter.custom.switch import NSwitch +from novelwriter.custom.pageddialog import NPagedDialog +from novelwriter.custom.configlayout import NConfigLayout from novelwriter.dialogs.quotes import GuiQuoteSelect logger = logging.getLogger(__name__) -class GuiPreferences(PagedDialog): +class GuiPreferences(NPagedDialog): def __init__(self, mainGui): super().__init__(parent=mainGui) @@ -161,7 +163,7 @@ class GuiPreferencesGeneral(QWidget): self.mainTheme = prefsGui.mainGui.mainTheme # The Form - self.mainForm = QConfigLayout() + self.mainForm = NConfigLayout() self.mainForm.setHelpTextStyle(self.mainTheme.helpText) self.setLayout(self.mainForm) @@ -250,7 +252,7 @@ class GuiPreferencesGeneral(QWidget): # ============ self.mainForm.addGroupLabel(self.tr("GUI Settings")) - self.emphLabels = QSwitch() + self.emphLabels = NSwitch() self.emphLabels.setChecked(self.mainConf.emphLabels) self.mainForm.addRow( self.tr("Emphasise partition and chapter labels"), @@ -258,7 +260,7 @@ class GuiPreferencesGeneral(QWidget): self.tr("Makes them stand out in the project tree."), ) - self.showFullPath = QSwitch() + self.showFullPath = NSwitch() self.showFullPath.setChecked(self.mainConf.showFullPath) self.mainForm.addRow( self.tr("Show full path in document header"), @@ -266,7 +268,7 @@ class GuiPreferencesGeneral(QWidget): self.tr("Add the parent folder names to the header.") ) - self.hideVScroll = QSwitch() + self.hideVScroll = NSwitch() self.hideVScroll.setChecked(self.mainConf.hideVScroll) self.mainForm.addRow( self.tr("Hide vertical scroll bars in main windows"), @@ -274,7 +276,7 @@ class GuiPreferencesGeneral(QWidget): self.tr("Scrolling available with mouse wheel and keys only.") ) - self.hideHScroll = QSwitch() + self.hideHScroll = NSwitch() self.hideHScroll.setChecked(self.mainConf.hideHScroll) self.mainForm.addRow( self.tr("Hide horizontal scroll bars in main windows"), @@ -343,7 +345,7 @@ class GuiPreferencesProjects(QWidget): self.mainTheme = prefsGui.mainGui.mainTheme # The Form - self.mainForm = QConfigLayout() + self.mainForm = NConfigLayout() self.mainForm.setHelpTextStyle(self.mainTheme.helpText) self.setLayout(self.mainForm) @@ -392,7 +394,7 @@ class GuiPreferencesProjects(QWidget): ) # Run when closing - self.backupOnClose = QSwitch() + self.backupOnClose = NSwitch() self.backupOnClose.setChecked(self.mainConf.backupOnClose) self.backupOnClose.toggled.connect(self._toggledBackupOnClose) self.mainForm.addRow( @@ -403,7 +405,7 @@ class GuiPreferencesProjects(QWidget): # Ask before backup # Only enabled when "Run when closing" is checked - self.askBeforeBackup = QSwitch() + self.askBeforeBackup = NSwitch() self.askBeforeBackup.setChecked(self.mainConf.askBeforeBackup) self.askBeforeBackup.setEnabled(self.mainConf.backupOnClose) self.mainForm.addRow( @@ -417,7 +419,7 @@ class GuiPreferencesProjects(QWidget): self.mainForm.addGroupLabel(self.tr("Session Timer")) # Pause when idle - self.stopWhenIdle = QSwitch() + self.stopWhenIdle = NSwitch() self.stopWhenIdle.setChecked(self.mainConf.stopWhenIdle) self.mainForm.addRow( self.tr("Pause the session timer when not writing"), @@ -499,7 +501,7 @@ class GuiPreferencesDocuments(QWidget): self.mainTheme = prefsGui.mainGui.mainTheme # The Form - self.mainForm = QConfigLayout() + self.mainForm = NConfigLayout() self.mainForm.setHelpTextStyle(self.mainTheme.helpText) self.setLayout(self.mainForm) @@ -566,7 +568,7 @@ class GuiPreferencesDocuments(QWidget): ) # Focus Mode Footer - self.hideFocusFooter = QSwitch() + self.hideFocusFooter = NSwitch() self.hideFocusFooter.setChecked(self.mainConf.hideFocusFooter) self.mainForm.addRow( self.tr("Hide document footer in \"Focus Mode\""), @@ -575,7 +577,7 @@ class GuiPreferencesDocuments(QWidget): ) # Justify Text - self.doJustify = QSwitch() + self.doJustify = NSwitch() self.doJustify.setChecked(self.mainConf.doJustify) self.mainForm.addRow( self.tr("Justify the text margins"), @@ -658,7 +660,7 @@ class GuiPreferencesEditor(QWidget): self.mainTheme = prefsGui.mainGui.mainTheme # The Form - self.mainForm = QConfigLayout() + self.mainForm = NConfigLayout() self.mainForm.setHelpTextStyle(self.mainTheme.helpText) self.setLayout(self.mainForm) @@ -727,7 +729,7 @@ class GuiPreferencesEditor(QWidget): ) # Include Notes in Word Count - self.incNotesWCount = QSwitch() + self.incNotesWCount = NSwitch() self.incNotesWCount.setChecked(self.mainConf.incNotesWCount) self.mainForm.addRow( self.tr("Include project notes in status bar word count"), @@ -739,7 +741,7 @@ class GuiPreferencesEditor(QWidget): self.mainForm.addGroupLabel(self.tr("Writing Guides")) # Show Tabs and Spaces - self.showTabsNSpaces = QSwitch() + self.showTabsNSpaces = NSwitch() self.showTabsNSpaces.setChecked(self.mainConf.showTabsNSpaces) self.mainForm.addRow( self.tr("Show tabs and spaces"), @@ -747,7 +749,7 @@ class GuiPreferencesEditor(QWidget): ) # Show Line Endings - self.showLineEndings = QSwitch() + self.showLineEndings = NSwitch() self.showLineEndings.setChecked(self.mainConf.showLineEndings) self.mainForm.addRow( self.tr("Show line endings"), @@ -772,7 +774,7 @@ class GuiPreferencesEditor(QWidget): ) # Typewriter Scrolling - self.autoScroll = QSwitch() + self.autoScroll = NSwitch() self.autoScroll.setChecked(self.mainConf.autoScroll) self.mainForm.addRow( self.tr("Typewriter style scrolling when you type"), @@ -831,7 +833,7 @@ class GuiPreferencesSyntax(QWidget): self.mainTheme = prefsGui.mainGui.mainTheme # The Form - self.mainForm = QConfigLayout() + self.mainForm = NConfigLayout() self.mainForm.setHelpTextStyle(self.mainTheme.helpText) self.setLayout(self.mainForm) @@ -839,7 +841,7 @@ class GuiPreferencesSyntax(QWidget): # ================= self.mainForm.addGroupLabel(self.tr("Quotes & Dialogue")) - self.highlightQuotes = QSwitch() + self.highlightQuotes = NSwitch() self.highlightQuotes.setChecked(self.mainConf.highlightQuotes) self.highlightQuotes.toggled.connect(self._toggleHighlightQuotes) self.mainForm.addRow( @@ -848,7 +850,7 @@ class GuiPreferencesSyntax(QWidget): self.tr("Applies to the document editor only.") ) - self.allowOpenSQuote = QSwitch() + self.allowOpenSQuote = NSwitch() self.allowOpenSQuote.setChecked(self.mainConf.allowOpenSQuote) self.mainForm.addRow( self.tr("Allow open-ended single quotes"), @@ -856,7 +858,7 @@ class GuiPreferencesSyntax(QWidget): self.tr("Highlight single-quoted line with no closing quote.") ) - self.allowOpenDQuote = QSwitch() + self.allowOpenDQuote = NSwitch() self.allowOpenDQuote.setChecked(self.mainConf.allowOpenDQuote) self.mainForm.addRow( self.tr("Allow open-ended double quotes"), @@ -868,7 +870,7 @@ class GuiPreferencesSyntax(QWidget): # ============= self.mainForm.addGroupLabel(self.tr("Text Emphasis")) - self.highlightEmph = QSwitch() + self.highlightEmph = NSwitch() self.highlightEmph.setChecked(self.mainConf.highlightEmph) self.mainForm.addRow( self.tr("Add highlight colour to emphasised text"), @@ -881,7 +883,7 @@ class GuiPreferencesSyntax(QWidget): self.mainForm.addGroupLabel(self.tr("Text Errors")) - self.showMultiSpaces = QSwitch() + self.showMultiSpaces = NSwitch() self.showMultiSpaces.setChecked(self.mainConf.showMultiSpaces) self.mainForm.addRow( self.tr("Highlight multiple or trailing spaces"), @@ -937,7 +939,7 @@ class GuiPreferencesAutomation(QWidget): self.mainTheme = prefsGui.mainGui.mainTheme # The Form - self.mainForm = QConfigLayout() + self.mainForm = NConfigLayout() self.mainForm.setHelpTextStyle(self.mainTheme.helpText) self.setLayout(self.mainForm) @@ -946,7 +948,7 @@ class GuiPreferencesAutomation(QWidget): self.mainForm.addGroupLabel(self.tr("Automatic Features")) # Auto-Select Word Under Cursor - self.autoSelect = QSwitch() + self.autoSelect = NSwitch() self.autoSelect.setChecked(self.mainConf.autoSelect) self.mainForm.addRow( self.tr("Auto-select word under cursor"), @@ -955,7 +957,7 @@ class GuiPreferencesAutomation(QWidget): ) # Auto-Replace as You Type Main Switch - self.doReplace = QSwitch() + self.doReplace = NSwitch() self.doReplace.setChecked(self.mainConf.doReplace) self.doReplace.toggled.connect(self._toggleAutoReplaceMain) self.mainForm.addRow( @@ -969,7 +971,7 @@ class GuiPreferencesAutomation(QWidget): self.mainForm.addGroupLabel(self.tr("Replace as You Type")) # Auto-Replace Single Quotes - self.doReplaceSQuote = QSwitch() + self.doReplaceSQuote = NSwitch() self.doReplaceSQuote.setChecked(self.mainConf.doReplaceSQuote) self.doReplaceSQuote.setEnabled(self.mainConf.doReplace) self.mainForm.addRow( @@ -979,7 +981,7 @@ class GuiPreferencesAutomation(QWidget): ) # Auto-Replace Double Quotes - self.doReplaceDQuote = QSwitch() + self.doReplaceDQuote = NSwitch() self.doReplaceDQuote.setChecked(self.mainConf.doReplaceDQuote) self.doReplaceDQuote.setEnabled(self.mainConf.doReplace) self.mainForm.addRow( @@ -989,7 +991,7 @@ class GuiPreferencesAutomation(QWidget): ) # Auto-Replace Hyphens - self.doReplaceDash = QSwitch() + self.doReplaceDash = NSwitch() self.doReplaceDash.setChecked(self.mainConf.doReplaceDash) self.doReplaceDash.setEnabled(self.mainConf.doReplace) self.mainForm.addRow( @@ -999,7 +1001,7 @@ class GuiPreferencesAutomation(QWidget): ) # Auto-Replace Dots - self.doReplaceDots = QSwitch() + self.doReplaceDots = NSwitch() self.doReplaceDots.setChecked(self.mainConf.doReplaceDots) self.doReplaceDots.setEnabled(self.mainConf.doReplace) self.mainForm.addRow( @@ -1033,7 +1035,7 @@ class GuiPreferencesAutomation(QWidget): ) # Use Thin Space - self.fmtPadThin = QSwitch() + self.fmtPadThin = NSwitch() self.fmtPadThin.setChecked(self.mainConf.fmtPadThin) self.fmtPadThin.setEnabled(self.mainConf.doReplace) self.mainForm.addRow( @@ -1092,7 +1094,7 @@ class GuiPreferencesQuotes(QWidget): self.mainTheme = prefsGui.mainGui.mainTheme # The Form - self.mainForm = QConfigLayout() + self.mainForm = NConfigLayout() self.mainForm.setHelpTextStyle(self.mainTheme.helpText) self.setLayout(self.mainForm) diff --git a/novelwriter/dialogs/projdetails.py b/novelwriter/dialogs/projdetails.py index 35d52345..02a31478 100644 --- a/novelwriter/dialogs/projdetails.py +++ b/novelwriter/dialogs/projdetails.py @@ -35,14 +35,15 @@ from PyQt5.QtWidgets import ( ) from novelwriter.common import formatTime, numberToRoman -from novelwriter.custom import PagedDialog, QSwitch from novelwriter.constants import nwUnicode +from novelwriter.custom.switch import NSwitch +from novelwriter.custom.pageddialog import NPagedDialog from novelwriter.gui.components import NovelSelector logger = logging.getLogger(__name__) -class GuiProjectDetails(PagedDialog): +class GuiProjectDetails(NPagedDialog): def __init__(self, mainGui): super().__init__(parent=mainGui) @@ -386,7 +387,7 @@ class GuiProjectDetailsContents(QWidget): self.dblLabel = QLabel(self.tr("Clear double pages")) self.dblLabel.setToolTip(dblHelp) - self.dblValue = QSwitch(self, 2*iPx, iPx) + self.dblValue = NSwitch(self, 2*iPx, iPx) self.dblValue.setChecked(clearDouble) self.dblValue.setToolTip(dblHelp) self.dblValue.clicked.connect(self._populateTree) diff --git a/novelwriter/dialogs/projsettings.py b/novelwriter/dialogs/projsettings.py index 23fb28c5..b406701f 100644 --- a/novelwriter/dialogs/projsettings.py +++ b/novelwriter/dialogs/projsettings.py @@ -35,12 +35,14 @@ from PyQt5.QtWidgets import ( from novelwriter.enum import nwAlert from novelwriter.common import simplified -from novelwriter.custom import QSwitch, PagedDialog, QConfigLayout +from novelwriter.custom.switch import NSwitch +from novelwriter.custom.pageddialog import NPagedDialog +from novelwriter.custom.configlayout import NConfigLayout logger = logging.getLogger(__name__) -class GuiProjectSettings(PagedDialog): +class GuiProjectSettings(NPagedDialog): TAB_MAIN = 0 TAB_STATUS = 1 @@ -196,7 +198,7 @@ class GuiProjectEditMain(QWidget): self.theProject = projGui.theProject # The Form - self.mainForm = QConfigLayout() + self.mainForm = NConfigLayout() self.mainForm.setHelpTextStyle(self.mainGui.mainTheme.helpText) self.setLayout(self.mainForm) @@ -255,7 +257,7 @@ class GuiProjectEditMain(QWidget): if spellIdx != -1: self.spellLang.setCurrentIndex(spellIdx) - self.doBackup = QSwitch(self) + self.doBackup = NSwitch(self) self.doBackup.setChecked(not self.theProject.data.doBackup) self.mainForm.addRow( self.tr("No backup on close"), diff --git a/novelwriter/gui/sidebar.py b/novelwriter/gui/sidebar.py index c27c8311..79ea27af 100644 --- a/novelwriter/gui/sidebar.py +++ b/novelwriter/gui/sidebar.py @@ -1,6 +1,6 @@ """ novelWriter – GUI Main Window SideBar -=========================================== +===================================== GUI class for the main window side bar File History: diff --git a/novelwriter/tools/build.py b/novelwriter/tools/build.py index f6f9ccd8..c1cdf291 100644 --- a/novelwriter/tools/build.py +++ b/novelwriter/tools/build.py @@ -46,11 +46,11 @@ from PyQt5.QtPrintSupport import QPrinter, QPrintPreviewDialog from novelwriter.enum import nwAlert, nwItemType, nwItemLayout, nwItemClass from novelwriter.error import formatException, logException from novelwriter.common import fuzzyTime, makeFileNameSafe -from novelwriter.custom import QSwitch from novelwriter.constants import nwConst, nwFiles from novelwriter.core.tomd import ToMarkdown from novelwriter.core.toodt import ToOdt from novelwriter.core.tohtml import ToHtml +from novelwriter.custom.switch import NSwitch logger = logging.getLogger(__name__) @@ -174,12 +174,12 @@ class GuiBuildNovel(QDialog): if langIdx != -1: self.buildLang.setCurrentIndex(langIdx) - self.hideScene = QSwitch(width=wS, height=hS) + self.hideScene = NSwitch(width=wS, height=hS) self.hideScene.setChecked( pOptions.getBool("GuiBuildNovel", "hideScene", False) ) - self.hideSection = QSwitch(width=wS, height=hS) + self.hideSection = NSwitch(width=wS, height=hS) self.hideSection.setChecked( pOptions.getBool("GuiBuildNovel", "hideSection", True) ) @@ -291,12 +291,12 @@ class GuiBuildNovel(QDialog): self.styleForm = QGridLayout(self) self.styleGroup.setLayout(self.styleForm) - self.justifyText = QSwitch(width=wS, height=hS) + self.justifyText = NSwitch(width=wS, height=hS) self.justifyText.setChecked( pOptions.getBool("GuiBuildNovel", "justifyText", False) ) - self.noStyling = QSwitch(width=wS, height=hS) + self.noStyling = NSwitch(width=wS, height=hS) self.noStyling.setChecked( pOptions.getBool("GuiBuildNovel", "noStyling", False) ) @@ -316,22 +316,22 @@ class GuiBuildNovel(QDialog): self.textForm = QGridLayout(self) self.textGroup.setLayout(self.textForm) - self.includeSynopsis = QSwitch(width=wS, height=hS) + self.includeSynopsis = NSwitch(width=wS, height=hS) self.includeSynopsis.setChecked( pOptions.getBool("GuiBuildNovel", "incSynopsis", False) ) - self.includeComments = QSwitch(width=wS, height=hS) + self.includeComments = NSwitch(width=wS, height=hS) self.includeComments.setChecked( pOptions.getBool("GuiBuildNovel", "incComments", False) ) - self.includeKeywords = QSwitch(width=wS, height=hS) + self.includeKeywords = NSwitch(width=wS, height=hS) self.includeKeywords.setChecked( pOptions.getBool("GuiBuildNovel", "incKeywords", False) ) - self.includeBody = QSwitch(width=wS, height=hS) + self.includeBody = NSwitch(width=wS, height=hS) self.includeBody.setChecked( pOptions.getBool("GuiBuildNovel", "incBodyText", True) ) @@ -371,7 +371,7 @@ class GuiBuildNovel(QDialog): rootLabel = QLabel(nwItem.itemName) rootLabel.setWordWrap(True) - rootValue = QSwitch(width=wS, height=hS) + rootValue = NSwitch(width=wS, height=hS) rootValue.setChecked(tHandle not in rootFilter) self.rootSelection[tHandle] = rootValue @@ -390,17 +390,17 @@ class GuiBuildNovel(QDialog): self.fileForm = QGridLayout(self) self.fileGroup.setLayout(self.fileForm) - self.novelFiles = QSwitch(width=wS, height=hS) + self.novelFiles = NSwitch(width=wS, height=hS) self.novelFiles.setChecked( pOptions.getBool("GuiBuildNovel", "addNovel", True) ) - self.noteFiles = QSwitch(width=wS, height=hS) + self.noteFiles = NSwitch(width=wS, height=hS) self.noteFiles.setChecked( pOptions.getBool("GuiBuildNovel", "addNotes", False) ) - self.ignoreFlag = QSwitch(width=wS, height=hS) + self.ignoreFlag = NSwitch(width=wS, height=hS) self.ignoreFlag.setChecked( pOptions.getBool("GuiBuildNovel", "ignoreFlag", False) ) @@ -426,12 +426,12 @@ class GuiBuildNovel(QDialog): self.exportForm = QGridLayout(self) self.exportGroup.setLayout(self.exportForm) - self.replaceTabs = QSwitch(width=wS, height=hS) + self.replaceTabs = NSwitch(width=wS, height=hS) self.replaceTabs.setChecked( pOptions.getBool("GuiBuildNovel", "replaceTabs", False) ) - self.replaceUCode = QSwitch(width=wS, height=hS) + self.replaceUCode = NSwitch(width=wS, height=hS) self.replaceUCode.setChecked( pOptions.getBool("GuiBuildNovel", "replaceUCode", False) ) diff --git a/novelwriter/tools/lipsum.py b/novelwriter/tools/lipsum.py index 9f0679c4..e4db9f66 100644 --- a/novelwriter/tools/lipsum.py +++ b/novelwriter/tools/lipsum.py @@ -34,7 +34,7 @@ from PyQt5.QtWidgets import ( ) from novelwriter.common import readTextFile -from novelwriter.custom import QSwitch +from novelwriter.custom.switch import NSwitch logger = logging.getLogger(__name__) @@ -78,7 +78,7 @@ class GuiLipsum(QDialog): self.paraCount.setValue(5) self.randLabel = QLabel(self.tr("Randomise order")) - self.randSwitch = QSwitch() + self.randSwitch = NSwitch() self.formBox = QGridLayout() self.formBox.addWidget(self.headLabel, 0, 0, 1, 2, Qt.AlignLeft) diff --git a/novelwriter/tools/projwizard.py b/novelwriter/tools/projwizard.py index 3dee4861..8fdf2c8c 100644 --- a/novelwriter/tools/projwizard.py +++ b/novelwriter/tools/projwizard.py @@ -34,7 +34,7 @@ from PyQt5.QtWidgets import ( ) from novelwriter.common import makeFileNameSafe -from novelwriter.custom import QSwitch +from novelwriter.custom.switch import NSwitch logger = logging.getLogger(__name__) @@ -328,19 +328,19 @@ class ProjWizardCustomPage(QWizardPage): fS = self.mainConf.pxInt(4) # Root Folders - self.addPlot = QSwitch() + self.addPlot = NSwitch() self.addPlot.setChecked(True) self.addPlot.clicked.connect(self._syncSwitches) - self.addChar = QSwitch() + self.addChar = NSwitch() self.addChar.setChecked(True) self.addChar.clicked.connect(self._syncSwitches) - self.addWorld = QSwitch() + self.addWorld = NSwitch() self.addWorld.setChecked(False) self.addWorld.clicked.connect(self._syncSwitches) - self.addNotes = QSwitch() + self.addNotes = NSwitch() self.addNotes.setChecked(False) # Generate Content diff --git a/novelwriter/tools/writingstats.py b/novelwriter/tools/writingstats.py index a5981985..93da7f63 100644 --- a/novelwriter/tools/writingstats.py +++ b/novelwriter/tools/writingstats.py @@ -40,8 +40,8 @@ from PyQt5.QtWidgets import ( from novelwriter.enum import nwAlert from novelwriter.error import formatException from novelwriter.common import formatTime, checkInt, checkIntTuple, minmax -from novelwriter.custom import QSwitch from novelwriter.constants import nwConst, nwFiles +from novelwriter.custom.switch import NSwitch logger = logging.getLogger(__name__) @@ -190,37 +190,37 @@ class GuiWritingStats(QDialog): self.filterForm = QGridLayout(self) self.filterBox.setLayout(self.filterForm) - self.incNovel = QSwitch(width=2*sPx, height=sPx) + self.incNovel = NSwitch(width=2*sPx, height=sPx) self.incNovel.setChecked( pOptions.getBool("GuiWritingStats", "incNovel", True) ) self.incNovel.clicked.connect(self._updateListBox) - self.incNotes = QSwitch(width=2*sPx, height=sPx) + self.incNotes = NSwitch(width=2*sPx, height=sPx) self.incNotes.setChecked( pOptions.getBool("GuiWritingStats", "incNotes", True) ) self.incNotes.clicked.connect(self._updateListBox) - self.hideZeros = QSwitch(width=2*sPx, height=sPx) + self.hideZeros = NSwitch(width=2*sPx, height=sPx) self.hideZeros.setChecked( pOptions.getBool("GuiWritingStats", "hideZeros", True) ) self.hideZeros.clicked.connect(self._updateListBox) - self.hideNegative = QSwitch(width=2*sPx, height=sPx) + self.hideNegative = NSwitch(width=2*sPx, height=sPx) self.hideNegative.setChecked( pOptions.getBool("GuiWritingStats", "hideNegative", False) ) self.hideNegative.clicked.connect(self._updateListBox) - self.groupByDay = QSwitch(width=2*sPx, height=sPx) + self.groupByDay = NSwitch(width=2*sPx, height=sPx) self.groupByDay.setChecked( pOptions.getBool("GuiWritingStats", "groupByDay", False) ) self.groupByDay.clicked.connect(self._updateListBox) - self.showIdleTime = QSwitch(width=2*sPx, height=sPx) + self.showIdleTime = NSwitch(width=2*sPx, height=sPx) self.showIdleTime.setChecked( pOptions.getBool("GuiWritingStats", "showIdleTime", False) ) From 0f0c9eacb1110fabc4a365740f463b2c5e88ff1e Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 21 Feb 2023 16:47:30 +0100 Subject: [PATCH 04/74] Rename custom to extensions to avoid import issues --- novelwriter/dialogs/docmerge.py | 4 +-- novelwriter/dialogs/docsplit.py | 4 +-- novelwriter/dialogs/preferences.py | 6 ++-- novelwriter/dialogs/projdetails.py | 4 +-- novelwriter/dialogs/projsettings.py | 6 ++-- .../{custom => extensions}/__init__.py | 0 .../{custom => extensions}/configlayout.py | 0 .../{custom => extensions}/pageddialog.py | 0 .../{custom => extensions}/pagedsidebar.py | 29 ++++++++++++++++--- novelwriter/{custom => extensions}/switch.py | 0 novelwriter/tools/build.py | 2 +- novelwriter/tools/lipsum.py | 2 +- novelwriter/tools/projwizard.py | 2 +- novelwriter/tools/writingstats.py | 2 +- 14 files changed, 41 insertions(+), 20 deletions(-) rename novelwriter/{custom => extensions}/__init__.py (100%) rename novelwriter/{custom => extensions}/configlayout.py (100%) rename novelwriter/{custom => extensions}/pageddialog.py (100%) rename novelwriter/{custom => extensions}/pagedsidebar.py (64%) rename novelwriter/{custom => extensions}/switch.py (100%) diff --git a/novelwriter/dialogs/docmerge.py b/novelwriter/dialogs/docmerge.py index 30e42345..db2099e0 100644 --- a/novelwriter/dialogs/docmerge.py +++ b/novelwriter/dialogs/docmerge.py @@ -33,8 +33,8 @@ from PyQt5.QtWidgets import ( QListWidget, QListWidgetItem, QVBoxLayout, ) -from novelwriter.custom.switch import NSwitch -from novelwriter.custom.configlayout import NHelpLabel +from novelwriter.extensions.switch import NSwitch +from novelwriter.extensions.configlayout import NHelpLabel logger = logging.getLogger(__name__) diff --git a/novelwriter/dialogs/docsplit.py b/novelwriter/dialogs/docsplit.py index 6e534390..f4fb8327 100644 --- a/novelwriter/dialogs/docsplit.py +++ b/novelwriter/dialogs/docsplit.py @@ -33,8 +33,8 @@ from PyQt5.QtWidgets import ( QListWidgetItem, QDialogButtonBox, QLabel, QGridLayout ) -from novelwriter.custom.switch import NSwitch -from novelwriter.custom.configlayout import NHelpLabel +from novelwriter.extensions.switch import NSwitch +from novelwriter.extensions.configlayout import NHelpLabel logger = logging.getLogger(__name__) diff --git a/novelwriter/dialogs/preferences.py b/novelwriter/dialogs/preferences.py index fff952ad..c450e455 100644 --- a/novelwriter/dialogs/preferences.py +++ b/novelwriter/dialogs/preferences.py @@ -33,10 +33,10 @@ from PyQt5.QtWidgets import ( QLineEdit, QFileDialog, QFontDialog, QDoubleSpinBox ) -from novelwriter.custom.switch import NSwitch -from novelwriter.custom.pageddialog import NPagedDialog -from novelwriter.custom.configlayout import NConfigLayout from novelwriter.dialogs.quotes import GuiQuoteSelect +from novelwriter.extensions.switch import NSwitch +from novelwriter.extensions.pageddialog import NPagedDialog +from novelwriter.extensions.configlayout import NConfigLayout logger = logging.getLogger(__name__) diff --git a/novelwriter/dialogs/projdetails.py b/novelwriter/dialogs/projdetails.py index 02a31478..d286b044 100644 --- a/novelwriter/dialogs/projdetails.py +++ b/novelwriter/dialogs/projdetails.py @@ -36,9 +36,9 @@ from PyQt5.QtWidgets import ( from novelwriter.common import formatTime, numberToRoman from novelwriter.constants import nwUnicode -from novelwriter.custom.switch import NSwitch -from novelwriter.custom.pageddialog import NPagedDialog from novelwriter.gui.components import NovelSelector +from novelwriter.extensions.switch import NSwitch +from novelwriter.extensions.pageddialog import NPagedDialog logger = logging.getLogger(__name__) diff --git a/novelwriter/dialogs/projsettings.py b/novelwriter/dialogs/projsettings.py index b406701f..11fc0e00 100644 --- a/novelwriter/dialogs/projsettings.py +++ b/novelwriter/dialogs/projsettings.py @@ -35,9 +35,9 @@ from PyQt5.QtWidgets import ( from novelwriter.enum import nwAlert from novelwriter.common import simplified -from novelwriter.custom.switch import NSwitch -from novelwriter.custom.pageddialog import NPagedDialog -from novelwriter.custom.configlayout import NConfigLayout +from novelwriter.extensions.switch import NSwitch +from novelwriter.extensions.pageddialog import NPagedDialog +from novelwriter.extensions.configlayout import NConfigLayout logger = logging.getLogger(__name__) diff --git a/novelwriter/custom/__init__.py b/novelwriter/extensions/__init__.py similarity index 100% rename from novelwriter/custom/__init__.py rename to novelwriter/extensions/__init__.py diff --git a/novelwriter/custom/configlayout.py b/novelwriter/extensions/configlayout.py similarity index 100% rename from novelwriter/custom/configlayout.py rename to novelwriter/extensions/configlayout.py diff --git a/novelwriter/custom/pageddialog.py b/novelwriter/extensions/pageddialog.py similarity index 100% rename from novelwriter/custom/pageddialog.py rename to novelwriter/extensions/pageddialog.py diff --git a/novelwriter/custom/pagedsidebar.py b/novelwriter/extensions/pagedsidebar.py similarity index 64% rename from novelwriter/custom/pagedsidebar.py rename to novelwriter/extensions/pagedsidebar.py index 485d71a7..e4c70348 100644 --- a/novelwriter/custom/pagedsidebar.py +++ b/novelwriter/extensions/pagedsidebar.py @@ -25,7 +25,7 @@ along with this program. If not, see . """ from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QToolBar, QToolButton +from PyQt5.QtWidgets import QActionGroup, QSizePolicy, QToolBar, QToolButton, QWidget class NPagedSideBar(QToolBar): @@ -34,21 +34,38 @@ class NPagedSideBar(QToolBar): super().__init__(parent=parent) self._buttons = [] + self._actions = [] + self._group = QActionGroup(self) + self._group.setExclusive(True) self.setMovable(False) self.setOrientation(Qt.Vertical) + stretch = QWidget(self) + stretch.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self._stretchAction = self.addWidget(stretch) + return - def addLabel(self, text): + def addLabel(self, text, height): """Add a new label to the toolbar. """ return - def addButton(self, text): + def addButton(self, text, height): """Add a new button to the toolbar. """ - return + button = NToolLabelButton(self) + button.setFixedHeight(height) + button.setText(text) + + action = self.insertWidget(self._stretchAction, button) + self._group.addAction(action) + + self._buttons.append(button) + self._actions.append(action) + + return action # END Class NPagedSideBar @@ -57,6 +74,10 @@ class NToolLabelButton(QToolButton): def __init__(self, parent): super().__init__(parent=parent) + + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.setCheckable(True) + return # END Class NToolLabelButton diff --git a/novelwriter/custom/switch.py b/novelwriter/extensions/switch.py similarity index 100% rename from novelwriter/custom/switch.py rename to novelwriter/extensions/switch.py diff --git a/novelwriter/tools/build.py b/novelwriter/tools/build.py index c1cdf291..b90893b2 100644 --- a/novelwriter/tools/build.py +++ b/novelwriter/tools/build.py @@ -50,7 +50,7 @@ from novelwriter.constants import nwConst, nwFiles from novelwriter.core.tomd import ToMarkdown from novelwriter.core.toodt import ToOdt from novelwriter.core.tohtml import ToHtml -from novelwriter.custom.switch import NSwitch +from novelwriter.extensions.switch import NSwitch logger = logging.getLogger(__name__) diff --git a/novelwriter/tools/lipsum.py b/novelwriter/tools/lipsum.py index e4db9f66..1047dd60 100644 --- a/novelwriter/tools/lipsum.py +++ b/novelwriter/tools/lipsum.py @@ -34,7 +34,7 @@ from PyQt5.QtWidgets import ( ) from novelwriter.common import readTextFile -from novelwriter.custom.switch import NSwitch +from novelwriter.extensions.switch import NSwitch logger = logging.getLogger(__name__) diff --git a/novelwriter/tools/projwizard.py b/novelwriter/tools/projwizard.py index 8fdf2c8c..da6a0578 100644 --- a/novelwriter/tools/projwizard.py +++ b/novelwriter/tools/projwizard.py @@ -34,7 +34,7 @@ from PyQt5.QtWidgets import ( ) from novelwriter.common import makeFileNameSafe -from novelwriter.custom.switch import NSwitch +from novelwriter.extensions.switch import NSwitch logger = logging.getLogger(__name__) diff --git a/novelwriter/tools/writingstats.py b/novelwriter/tools/writingstats.py index 93da7f63..cb2edcf8 100644 --- a/novelwriter/tools/writingstats.py +++ b/novelwriter/tools/writingstats.py @@ -41,7 +41,7 @@ from novelwriter.enum import nwAlert from novelwriter.error import formatException from novelwriter.common import formatTime, checkInt, checkIntTuple, minmax from novelwriter.constants import nwConst, nwFiles -from novelwriter.custom.switch import NSwitch +from novelwriter.extensions.switch import NSwitch logger = logging.getLogger(__name__) From 981111d3806c945d356122320b1d2c007076e1e5 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 21 Feb 2023 18:14:20 +0100 Subject: [PATCH 05/74] Complete the paged sidebar widget --- novelwriter/extensions/pagedsidebar.py | 141 ++++++++++++++++++++++--- 1 file changed, 129 insertions(+), 12 deletions(-) diff --git a/novelwriter/extensions/pagedsidebar.py b/novelwriter/extensions/pagedsidebar.py index e4c70348..f095410b 100644 --- a/novelwriter/extensions/pagedsidebar.py +++ b/novelwriter/extensions/pagedsidebar.py @@ -5,7 +5,7 @@ A custom widget for making a sidebar for flipping through pages File History: Created: 2023-02-21 [2.1b1] NPagedSideBar -Created: 2023-02-21 [2.1b1] NToolLabelButton +Created: 2023-02-21 [2.1b1] NPagedToolButton This file is a part of novelWriter Copyright 2018–2023, Veronica Berglyd Olsen @@ -24,8 +24,12 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QActionGroup, QSizePolicy, QToolBar, QToolButton, QWidget +from PyQt5.QtCore import QRectF, Qt +from PyQt5.QtGui import QColor, QPainter +from PyQt5.QtWidgets import ( + QButtonGroup, QLabel, QSizePolicy, QStyle, QStyleOptionToolButton, QToolBar, + QToolButton, QWidget +) class NPagedSideBar(QToolBar): @@ -35,8 +39,10 @@ class NPagedSideBar(QToolBar): self._buttons = [] self._actions = [] - self._group = QActionGroup(self) + self._group = QButtonGroup(self) self._group.setExclusive(True) + self._labelCol = None + self._spacerHeight = self.fontMetrics().height() // 2 self.setMovable(False) self.setOrientation(Qt.Vertical) @@ -47,20 +53,40 @@ class NPagedSideBar(QToolBar): return - def addLabel(self, text, height): - """Add a new label to the toolbar. + def setLabelColor(self, color): + """Set the text color for the labels. """ + if isinstance(color, list): + self._labelCol = QColor(*color) + elif isinstance(color, QColor): + self._labelCol = color return - def addButton(self, text, height): + def addSeparator(self): + """Add a spacer widget. + """ + spacer = QWidget(self) + spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + spacer.setFixedHeight(self._spacerHeight) + self.insertWidget(self._stretchAction, spacer) + return + + def addLabel(self, text): + """Add a new label to the toolbar. + """ + label = NPagedToolLabel(self, self._labelCol) + label.setText(text) + self.insertWidget(self._stretchAction, label) + return + + def addButton(self, text): """Add a new button to the toolbar. """ - button = NToolLabelButton(self) - button.setFixedHeight(height) + button = NPagedToolButton(self) button.setText(text) action = self.insertWidget(self._stretchAction, button) - self._group.addAction(action) + self._group.addButton(button) self._buttons.append(button) self._actions.append(action) @@ -70,7 +96,9 @@ class NPagedSideBar(QToolBar): # END Class NPagedSideBar -class NToolLabelButton(QToolButton): +class NPagedToolButton(QToolButton): + + __slots__ = ["_bH", "_tM", "_lM", "_cR"] def __init__(self, parent): super().__init__(parent=parent) @@ -78,6 +106,95 @@ class NToolLabelButton(QToolButton): self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) self.setCheckable(True) + fH = self.fontMetrics().height() + self._bH = round(fH * 1.7) + self._tM = (self._bH - fH) // 2 + self._lM = self.style().pixelMetric(QStyle.PM_ButtonMargin) + self._cR = self._lM // 2 + self.setFixedHeight(self._bH) + return -# END Class NToolLabelButton + def paintEvent(self, event): + """Overload the paint event to draw a simple, left aligned text + label, with a highlight when selected and alternative base + colour when hovered. + """ + opt = QStyleOptionToolButton() + opt.initFrom(self) + + paint = QPainter(self) + paint.setRenderHint(QPainter.Antialiasing, True) + paint.setPen(Qt.NoPen) + + width = self.width() + height = self.height() + palette = self.palette() + + if opt.state & QStyle.State_MouseOver: + backCol = palette.alternateBase() + paint.setBrush(backCol) + paint.setOpacity(0.5) + paint.drawRoundedRect(0, 0, width, height, self._cR, self._cR) + + if self.isChecked(): + backCol = palette.highlight() + paint.setBrush(backCol) + paint.setOpacity(0.5) + paint.drawRoundedRect(0, 0, width, height, self._cR, self._cR) + textCol = palette.highlightedText().color() + else: + textCol = palette.text().color() + + tW = width - 2*self._lM + tH = height - 2*self._tM + + paint.setPen(textCol) + paint.setOpacity(1.0) + paint.drawText(QRectF(self._lM, self._tM, tW, tH), Qt.AlignLeft, self.text()) + + return + +# END Class NPagedToolButton + + +class NPagedToolLabel(QLabel): + + __slots__ = ["_bH", "_tM", "_lM", "_textCol"] + + def __init__(self, parent, textColor=None): + super().__init__(parent=parent) + + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + + fH = self.fontMetrics().height() + self._bH = round(fH * 1.7) + self._tM = (self._bH - fH) // 2 + self._lM = self.style().pixelMetric(QStyle.PM_ButtonMargin) + self.setFixedHeight(self._bH) + + self._textCol = textColor or self.palette().text().color() + + return + + def paintEvent(self, event): + """Overload the paint event to draw a simple, left aligned text + label that matches the button style. + """ + paint = QPainter(self) + paint.setRenderHint(QPainter.Antialiasing, True) + paint.setPen(Qt.NoPen) + + width = self.width() + height = self.height() + + tW = width - 2*self._lM + tH = height - 2*self._tM + + paint.setPen(self._textCol) + paint.setOpacity(1.0) + paint.drawText(QRectF(self._lM, self._tM, tW, tH), Qt.AlignLeft, self.text()) + + return + +# END Class NPagedToolLabel From 9d61432641ff8bd9794a14db0c7a84f14240fb01 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 21 Feb 2023 18:15:04 +0100 Subject: [PATCH 06/74] Add a basic build settings class, and add the paged sidebar to the mbuild dialog --- novelwriter/mbuild/buildsettings.py | 114 ++++++++++++++++++++++++++++ novelwriter/tools/mbuild.py | 58 ++++++++++++-- 2 files changed, 166 insertions(+), 6 deletions(-) create mode 100644 novelwriter/mbuild/buildsettings.py diff --git a/novelwriter/mbuild/buildsettings.py b/novelwriter/mbuild/buildsettings.py new file mode 100644 index 00000000..6f492862 --- /dev/null +++ b/novelwriter/mbuild/buildsettings.py @@ -0,0 +1,114 @@ +""" +novelWriter – Build Settings Class +================================== +A class to hold build settings for the build tool + +File History: +Created: 2023-02-14 [2.1b1] + +This file is a part of novelWriter +Copyright 2018–2023, 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 . +""" + +import logging + +from PyQt5.QtCore import QT_TRANSLATE_NOOP + +logger = logging.getLogger(__name__) + +# The Settings Template +# ===================== +# Each entry contains a tuple on the form: +# (type, default, [min value, max value]) + +SETTINGS_TEMPLATE = { + "headings.fmtTitle": (str, "%title%"), + "headings.fmtChapter": (str, "%title%"), + "headings.fmtUnnumbered": (str, "%title%"), + "headings.fmtScene": (str, "%title%"), + "headings.fmtSection": (str, "%title%"), + "headings.hideScene": (bool, False), + "headings.hideSection": (bool, False), + "format.buildLang": (str, "en_GB"), + "format.textFont": (str, ""), + "format.textSize": (str, ""), + "format.lineHeight": (float, 1.15, 0.75, 3.0), + "format.justifyText": (bool, False), + "format.stripUnicode": (bool, False), + "odt.addColours": (bool, True), + "html.addStyles": (bool, False), +} + +FILTER_TEMPLATE = { + "filter.includeSynopsis": (bool, False), + "filter.includeComments": (bool, False), + "filter.includeKeywords": (bool, False), + "filter.includeBody": (bool, True), +} + +SETTINGS_LABELS = { + "headings": QT_TRANSLATE_NOOP("Builds", "Headings"), + "headings.fmtTitle": QT_TRANSLATE_NOOP("Builds", "Title Heading"), + "headings.fmtChapter": QT_TRANSLATE_NOOP("Builds", "Chapter Heading"), + "headings.fmtUnnumbered": QT_TRANSLATE_NOOP("Builds", "Unnumbered Chapter"), + "headings.fmtScene": QT_TRANSLATE_NOOP("Builds", "Scene Heading"), + "headings.fmtSection": QT_TRANSLATE_NOOP("Builds", "Section Heading"), + "headings.hideScene": QT_TRANSLATE_NOOP("Builds", "Hide Scene"), + "headings.hideSection": QT_TRANSLATE_NOOP("Builds", "Hide Section"), + + "format": QT_TRANSLATE_NOOP("Builds", "Text Format"), + "format.buildLang": QT_TRANSLATE_NOOP("Builds", "Build Language"), + "format.textFont": QT_TRANSLATE_NOOP("Builds", "Font Family"), + "format.textSize": QT_TRANSLATE_NOOP("Builds", "Font Size"), + "format.lineHeight": QT_TRANSLATE_NOOP("Builds", "Line Height"), + "format.justifyText": QT_TRANSLATE_NOOP("Builds", "Justify Text Margins"), + "format.stripUnicode": QT_TRANSLATE_NOOP("Builds", "Replace Unicode Characters"), + + "odt": QT_TRANSLATE_NOOP("Builds", "Open Document"), + "odt.addColours": QT_TRANSLATE_NOOP("Builds", "Add Highlight Colours"), + + "html": QT_TRANSLATE_NOOP("Builds", "HTML"), + "html.addStyles": QT_TRANSLATE_NOOP("Builds", "Add CSS Styles"), +} + +FILTER_LABELS = { + "filter.includeSynopsis": QT_TRANSLATE_NOOP("Builds", "Synopsis"), + "filter.includeComments": QT_TRANSLATE_NOOP("Builds", "Comments"), + "filter.includeKeywords": QT_TRANSLATE_NOOP("Builds", "Keywords"), + "filter.includeBody": QT_TRANSLATE_NOOP("Builds", "Body Text"), +} + + +class BuildSettings: + + def __init__(self): + + self._data = {} + + self._loadTemplate() + + return + + ## + # Internal Functions + ## + + def _loadTemplate(self): + """Populate the data dictionary from the template. + """ + return + +# END Class BuildSettings diff --git a/novelwriter/tools/mbuild.py b/novelwriter/tools/mbuild.py index 22c9af2f..47dcc34c 100644 --- a/novelwriter/tools/mbuild.py +++ b/novelwriter/tools/mbuild.py @@ -4,7 +4,7 @@ novelWriter – GUI Build Manuscript GUI classes for the Manuscript build tool File History: -Created: 2023-02-13 [2.1-a0] +Created: 2023-02-13 [2.1b1] This file is a part of novelWriter Copyright 2018–2023, Veronica Berglyd Olsen @@ -24,11 +24,16 @@ along with this program. If not, see . """ import logging + import novelwriter -from PyQt5.QtWidgets import QDialog, QHBoxLayout, QTabWidget, QWidget +from PyQt5.QtWidgets import ( + QAbstractItemView, QDialog, QHBoxLayout, QTabWidget, QTreeWidget, + QWidget +) -from novelwriter.custom import VerticalTabBar +from novelwriter.extensions.pageddialog import NVerticalTabBar +from novelwriter.extensions.pagedsidebar import NPagedSideBar logger = logging.getLogger(__name__) @@ -40,16 +45,41 @@ class GuiBuildManuscript(QDialog): self.mainConf = novelwriter.CONFIG self.mainGui = mainGui + self.mainTheme = mainGui.mainTheme self.theProject = mainGui.theProject self.setWindowTitle(self.tr("Build Manuscript")) self.setMinimumWidth(self.mainConf.pxInt(700)) self.setMinimumHeight(self.mainConf.pxInt(600)) - # Optiuons Area - # ============= + # Style + mPx = self.mainConf.pxInt(150) + # tPx = int(self.mainTheme.fontPixelSize * 1.5) - self.optTabBar = VerticalTabBar(self) + # Options SideBar + # =============== + + self.optSideBar = NPagedSideBar(self) + self.optSideBar.setMinimumWidth(mPx) + self.optSideBar.setMaximumWidth(mPx) + self.optSideBar.setLabelColor(self.mainTheme.helpText) + + self.optSideBar.addLabel(self.tr("Options")) + self.optFilters = self.optSideBar.addButton(self.tr("Filters")) + self.optHeadings = self.optSideBar.addButton(self.tr("Headings")) + self.optFormat = self.optSideBar.addButton(self.tr("Format")) + self.optContent = self.optSideBar.addButton(self.tr("Content")) + self.optSideBar.addSeparator() + + self.optSideBar.addLabel(self.tr("Build")) + self.bldHtml = self.optSideBar.addButton(self.tr("HTML")) + self.bldMd = self.optSideBar.addButton(self.tr("Markdown")) + self.bldOdt = self.optSideBar.addButton(self.tr("Open Document")) + + # Options Area + # ============ + + self.optTabBar = NVerticalTabBar(self) self.optTabBar.setExpanding(False) self.optTabBox = QTabWidget(self) @@ -66,6 +96,7 @@ class GuiBuildManuscript(QDialog): # Assemble self.outerBox = QHBoxLayout() + self.outerBox.addWidget(self.optSideBar) self.outerBox.addWidget(self.optTabBox) self.setLayout(self.outerBox) @@ -80,6 +111,21 @@ class GuiBuildSelectionTab(QWidget): def __init__(self, buildMain): super().__init__(parent=buildMain) + self.optTree = QTreeWidget(self) + self.optTree.setSelectionMode(QAbstractItemView.SingleSelection) + self.optTree.setDragDropMode(QAbstractItemView.NoDragDrop) + self.optTree.setColumnCount(2) + self.optTree.setHeaderLabels([ + self.tr("Setting"), + self.tr("Value"), + ]) + self.optTree.setRootIsDecorated(False) + + self.outerBox = QHBoxLayout() + self.outerBox.addWidget(self.optTree) + + self.setLayout(self.outerBox) + return # END Class GuiBuildSelectionTab From a507e6416321362f3abba3bdf7ac65508d1d98b0 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 21 Feb 2023 18:46:10 +0100 Subject: [PATCH 07/74] No need for the mbuild folder --- novelwriter/{mbuild => core}/buildsettings.py | 0 novelwriter/{mbuild => core}/docbuild.py | 0 novelwriter/mbuild/__init__.py | 3 --- .../test_core_docbuild.py} | 14 +++++++------- 4 files changed, 7 insertions(+), 10 deletions(-) rename novelwriter/{mbuild => core}/buildsettings.py (100%) rename novelwriter/{mbuild => core}/docbuild.py (100%) delete mode 100644 novelwriter/mbuild/__init__.py rename tests/{test_mbuild/test_mbuild_docbuild.py => test_core/test_core_docbuild.py} (95%) diff --git a/novelwriter/mbuild/buildsettings.py b/novelwriter/core/buildsettings.py similarity index 100% rename from novelwriter/mbuild/buildsettings.py rename to novelwriter/core/buildsettings.py diff --git a/novelwriter/mbuild/docbuild.py b/novelwriter/core/docbuild.py similarity index 100% rename from novelwriter/mbuild/docbuild.py rename to novelwriter/core/docbuild.py diff --git a/novelwriter/mbuild/__init__.py b/novelwriter/mbuild/__init__.py deleted file mode 100644 index 5a2fd13d..00000000 --- a/novelwriter/mbuild/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -novelWriter – Manuscript Build Init File -""" diff --git a/tests/test_mbuild/test_mbuild_docbuild.py b/tests/test_core/test_core_docbuild.py similarity index 95% rename from tests/test_mbuild/test_mbuild_docbuild.py rename to tests/test_core/test_core_docbuild.py index ca1dea4b..c89c34fa 100644 --- a/tests/test_mbuild/test_mbuild_docbuild.py +++ b/tests/test_core/test_core_docbuild.py @@ -27,7 +27,7 @@ from mock import causeException, causeOSError from tools import ODT_IGNORE, cmpFiles from novelwriter.core.project import NWProject -from novelwriter.mbuild.docbuild import NWBuildDocument +from novelwriter.core.docbuild import NWBuildDocument BUILD_CONF = { "format.fmtTitle": "Title: %title%", @@ -53,7 +53,7 @@ BUILD_CONF = { @pytest.mark.core -def testMBuildDocBuild_OpenDocument(monkeypatch, mockGUI, prjLipsum, fncPath, tstPaths): +def testCoreDocBuild_OpenDocument(monkeypatch, mockGUI, prjLipsum, fncPath, tstPaths): """Test builing an open document manuscript. """ theProject = NWProject(mockGUI) @@ -157,11 +157,11 @@ def testMBuildDocBuild_OpenDocument(monkeypatch, mockGUI, prjLipsum, fncPath, ts "Build: Unknown item '0000000000000'", ] -# END Test testMBuildDocBuild_OpenDocument +# END Test testCoreDocBuild_OpenDocument @pytest.mark.core -def testMBuildDocBuild_HTML(monkeypatch, mockGUI, prjLipsum, fncPath, tstPaths): +def testCoreDocBuild_HTML(monkeypatch, mockGUI, prjLipsum, fncPath, tstPaths): """Test builing an HTML manuscript. """ theProject = NWProject(mockGUI) @@ -208,11 +208,11 @@ def testMBuildDocBuild_HTML(monkeypatch, mockGUI, prjLipsum, fncPath, tstPaths): assert docBuild.error == "OSError: Mock OSError" assert not docFile.is_file() -# END Test testMBuildDocBuild_HTML +# END Test testCoreDocBuild_HTML @pytest.mark.core -def testMBuildDocBuild_Markdown(monkeypatch, mockGUI, prjLipsum, fncPath, tstPaths): +def testCoreDocBuild_Markdown(monkeypatch, mockGUI, prjLipsum, fncPath, tstPaths): """Test builing an Markdown manuscript. """ theProject = NWProject(mockGUI) @@ -279,4 +279,4 @@ def testMBuildDocBuild_Markdown(monkeypatch, mockGUI, prjLipsum, fncPath, tstPat assert docBuild.error == "OSError: Mock OSError" assert not docFile.is_file() -# END Test testMBuildDocBuild_Markdown +# END Test testCoreDocBuild_Markdown From 8b2566b9fec4772c429c05aed44a3e7f43377738 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 22 Feb 2023 21:07:01 +0100 Subject: [PATCH 08/74] Add signal to paged sidebar --- novelwriter/extensions/pagedsidebar.py | 32 +++++++++++---- novelwriter/tools/mbuild.py | 57 +++++++++++++++++--------- 2 files changed, 62 insertions(+), 27 deletions(-) diff --git a/novelwriter/extensions/pagedsidebar.py b/novelwriter/extensions/pagedsidebar.py index f095410b..27b2639a 100644 --- a/novelwriter/extensions/pagedsidebar.py +++ b/novelwriter/extensions/pagedsidebar.py @@ -6,6 +6,7 @@ A custom widget for making a sidebar for flipping through pages File History: Created: 2023-02-21 [2.1b1] NPagedSideBar Created: 2023-02-21 [2.1b1] NPagedToolButton +Created: 2023-02-21 [2.1b1] NPagedToolLabel This file is a part of novelWriter Copyright 2018–2023, Veronica Berglyd Olsen @@ -24,8 +25,8 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -from PyQt5.QtCore import QRectF, Qt from PyQt5.QtGui import QColor, QPainter +from PyQt5.QtCore import QRectF, Qt, pyqtSignal, pyqtSlot from PyQt5.QtWidgets import ( QButtonGroup, QLabel, QSizePolicy, QStyle, QStyleOptionToolButton, QToolBar, QToolButton, QWidget @@ -34,16 +35,20 @@ from PyQt5.QtWidgets import ( class NPagedSideBar(QToolBar): + buttonClicked = pyqtSignal(int) + def __init__(self, parent): super().__init__(parent=parent) self._buttons = [] self._actions = [] - self._group = QButtonGroup(self) - self._group.setExclusive(True) self._labelCol = None self._spacerHeight = self.fontMetrics().height() // 2 + self._group = QButtonGroup(self) + self._group.setExclusive(True) + self._group.buttonClicked.connect(self._buttonClicked) + self.setMovable(False) self.setOrientation(Qt.Vertical) @@ -79,26 +84,39 @@ class NPagedSideBar(QToolBar): self.insertWidget(self._stretchAction, label) return - def addButton(self, text): + def addButton(self, text, buttonId=-1): """Add a new button to the toolbar. """ button = NPagedToolButton(self) button.setText(text) action = self.insertWidget(self._stretchAction, button) - self._group.addButton(button) + self._group.addButton(button, id=buttonId) self._buttons.append(button) self._actions.append(action) return action + ## + # Private Slots + ## + + @pyqtSlot("QAbstractButton*") + def _buttonClicked(self, button): + """A button was clicked in the group, emit its id. + """ + buttonId = self._group.id(button) + if buttonId != -1: + self.buttonClicked.emit(buttonId) + return + # END Class NPagedSideBar class NPagedToolButton(QToolButton): - __slots__ = ["_bH", "_tM", "_lM", "_cR"] + __slots__ = ("_bH", "_tM", "_lM", "_cR") def __init__(self, parent): super().__init__(parent=parent) @@ -160,7 +178,7 @@ class NPagedToolButton(QToolButton): class NPagedToolLabel(QLabel): - __slots__ = ["_bH", "_tM", "_lM", "_textCol"] + __slots__ = ("_bH", "_tM", "_lM", "_textCol") def __init__(self, parent, textColor=None): super().__init__(parent=parent) diff --git a/novelwriter/tools/mbuild.py b/novelwriter/tools/mbuild.py index 47dcc34c..47098ce4 100644 --- a/novelwriter/tools/mbuild.py +++ b/novelwriter/tools/mbuild.py @@ -24,15 +24,14 @@ along with this program. If not, see . """ import logging - import novelwriter +from PyQt5.QtCore import pyqtSlot from PyQt5.QtWidgets import ( - QAbstractItemView, QDialog, QHBoxLayout, QTabWidget, QTreeWidget, + QAbstractItemView, QDialog, QHBoxLayout, QStackedWidget, QTreeWidget, QWidget ) -from novelwriter.extensions.pageddialog import NVerticalTabBar from novelwriter.extensions.pagedsidebar import NPagedSideBar logger = logging.getLogger(__name__) @@ -40,6 +39,14 @@ logger = logging.getLogger(__name__) class GuiBuildManuscript(QDialog): + OPT_FILTERS = 1 + OPT_HEADINGS = 2 + OPT_FORMAT = 3 + OPT_CONTENT = 4 + BLD_HTML = 5 + BLD_MARKDOWN = 6 + BLD_ODT = 7 + def __init__(self, mainGui): super().__init__(parent=mainGui) @@ -65,44 +72,54 @@ class GuiBuildManuscript(QDialog): self.optSideBar.setLabelColor(self.mainTheme.helpText) self.optSideBar.addLabel(self.tr("Options")) - self.optFilters = self.optSideBar.addButton(self.tr("Filters")) - self.optHeadings = self.optSideBar.addButton(self.tr("Headings")) - self.optFormat = self.optSideBar.addButton(self.tr("Format")) - self.optContent = self.optSideBar.addButton(self.tr("Content")) + self.optSideBar.addButton(self.tr("Filters"), self.OPT_FILTERS) + self.optSideBar.addButton(self.tr("Headings"), self.OPT_HEADINGS) + self.optSideBar.addButton(self.tr("Format"), self.OPT_FORMAT) + self.optSideBar.addButton(self.tr("Content"), self.OPT_CONTENT) self.optSideBar.addSeparator() self.optSideBar.addLabel(self.tr("Build")) - self.bldHtml = self.optSideBar.addButton(self.tr("HTML")) - self.bldMd = self.optSideBar.addButton(self.tr("Markdown")) - self.bldOdt = self.optSideBar.addButton(self.tr("Open Document")) + self.optSideBar.addButton(self.tr("HTML"), self.BLD_HTML) + self.optSideBar.addButton(self.tr("Markdown"), self.BLD_MARKDOWN) + self.optSideBar.addButton(self.tr("Open Document"), self.BLD_ODT) + + self.optSideBar.buttonClicked.connect(self._stackPageSelected) # Options Area # ============ - self.optTabBar = NVerticalTabBar(self) - self.optTabBar.setExpanding(False) - - self.optTabBox = QTabWidget(self) - self.optTabBox.setTabBar(self.optTabBar) - self.optTabBox.setTabPosition(QTabWidget.West) - # Create Tabs self.optTabSelect = GuiBuildSelectionTab(self) self.optTabFormat = GuiBuildFormattingTab(self) # Add Tabs - self.optTabBox.addTab(self.optTabSelect, self.tr("Selection")) - self.optTabBox.addTab(self.optTabFormat, self.tr("Formatting")) + self.toolStack = QStackedWidget(self) + self.toolStack.addWidget(self.optTabSelect) + self.toolStack.addWidget(self.optTabFormat) # Assemble self.outerBox = QHBoxLayout() self.outerBox.addWidget(self.optSideBar) - self.outerBox.addWidget(self.optTabBox) + self.outerBox.addWidget(self.toolStack) self.setLayout(self.outerBox) return + ## + # Private Slots + ## + + @pyqtSlot(int) + def _stackPageSelected(self, pageId): + """Process a user request to switch page. + """ + if pageId == self.OPT_FILTERS: + self.toolStack.setCurrentWidget(self.optTabSelect) + elif pageId == self.OPT_FORMAT: + self.toolStack.setCurrentWidget(self.optTabFormat) + return + # END Class GuiBuildManuscript From 0a8ed86b7cc05dab9d21cc5a22e328b3875268bc Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 28 Feb 2023 18:24:09 +0100 Subject: [PATCH 09/74] Finish the build tool pages and add feature to set selected button --- novelwriter/extensions/pagedsidebar.py | 6 ++ novelwriter/tools/mbuild.py | 85 ++++++++++++++++++++++++-- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/novelwriter/extensions/pagedsidebar.py b/novelwriter/extensions/pagedsidebar.py index 27b2639a..26772ee8 100644 --- a/novelwriter/extensions/pagedsidebar.py +++ b/novelwriter/extensions/pagedsidebar.py @@ -98,6 +98,12 @@ class NPagedSideBar(QToolBar): return action + def setSelected(self, buttonId): + """Set the selected button. + """ + self._group.button(buttonId).setChecked(True) + return + ## # Private Slots ## diff --git a/novelwriter/tools/mbuild.py b/novelwriter/tools/mbuild.py index 47098ce4..0d3c29e3 100644 --- a/novelwriter/tools/mbuild.py +++ b/novelwriter/tools/mbuild.py @@ -89,13 +89,23 @@ class GuiBuildManuscript(QDialog): # ============ # Create Tabs - self.optTabSelect = GuiBuildSelectionTab(self) - self.optTabFormat = GuiBuildFormattingTab(self) + self.optTabSelect = GuiBuildFilterTab(self) + self.optTabHeadings = GuiBuildHeadingsTab(self) + self.optTabFormat = GuiBuildFormatTab(self) + self.optTabContent = GuiBuildContentTab(self) + self.buildTabHTML = GuiBuildHTMLTab(self) + self.buildTabMarkdown = GuiBuildMarkdownTab(self) + self.buildTabODT = GuiBuildODTTab(self) # Add Tabs self.toolStack = QStackedWidget(self) self.toolStack.addWidget(self.optTabSelect) + self.toolStack.addWidget(self.optTabHeadings) self.toolStack.addWidget(self.optTabFormat) + self.toolStack.addWidget(self.optTabContent) + self.toolStack.addWidget(self.buildTabHTML) + self.toolStack.addWidget(self.buildTabMarkdown) + self.toolStack.addWidget(self.buildTabODT) # Assemble self.outerBox = QHBoxLayout() @@ -104,6 +114,9 @@ class GuiBuildManuscript(QDialog): self.setLayout(self.outerBox) + # Set Default Tab + self.optSideBar.setSelected(self.OPT_FILTERS) + return ## @@ -116,14 +129,24 @@ class GuiBuildManuscript(QDialog): """ if pageId == self.OPT_FILTERS: self.toolStack.setCurrentWidget(self.optTabSelect) + elif pageId == self.OPT_HEADINGS: + self.toolStack.setCurrentWidget(self.optTabHeadings) elif pageId == self.OPT_FORMAT: self.toolStack.setCurrentWidget(self.optTabFormat) + elif pageId == self.OPT_CONTENT: + self.toolStack.setCurrentWidget(self.optTabContent) + elif pageId == self.BLD_HTML: + self.toolStack.setCurrentWidget(self.buildTabHTML) + elif pageId == self.BLD_MARKDOWN: + self.toolStack.setCurrentWidget(self.buildTabMarkdown) + elif pageId == self.BLD_ODT: + self.toolStack.setCurrentWidget(self.buildTabODT) return # END Class GuiBuildManuscript -class GuiBuildSelectionTab(QWidget): +class GuiBuildFilterTab(QWidget): def __init__(self, buildMain): super().__init__(parent=buildMain) @@ -145,14 +168,64 @@ class GuiBuildSelectionTab(QWidget): return -# END Class GuiBuildSelectionTab +# END Class GuiBuildFilterTab -class GuiBuildFormattingTab(QWidget): +class GuiBuildHeadingsTab(QWidget): def __init__(self, buildMain): super().__init__(parent=buildMain) return -# END Class GuiBuildFormattingTab +# END Class GuiBuildHeadingsTab + + +class GuiBuildFormatTab(QWidget): + + def __init__(self, buildMain): + super().__init__(parent=buildMain) + + return + +# END Class GuiBuildFormatTab + + +class GuiBuildContentTab(QWidget): + + def __init__(self, buildMain): + super().__init__(parent=buildMain) + + return + +# END Class GuiBuildContentTab + + +class GuiBuildHTMLTab(QWidget): + + def __init__(self, buildMain): + super().__init__(parent=buildMain) + + return + +# END Class GuiBuildHTMLTab + + +class GuiBuildMarkdownTab(QWidget): + + def __init__(self, buildMain): + super().__init__(parent=buildMain) + + return + +# END Class GuiBuildMarkdownTab + + +class GuiBuildODTTab(QWidget): + + def __init__(self, buildMain): + super().__init__(parent=buildMain) + + return + +# END Class GuiBuildODTTab From ba5b4ece3048ca7c1f4ad6f018bdd617d494661f Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 28 Feb 2023 21:22:24 +0100 Subject: [PATCH 10/74] Change menu entries so tests run properly --- novelwriter/gui/mainmenu.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py index dfde2bee..0bc63f80 100644 --- a/novelwriter/gui/mainmenu.py +++ b/novelwriter/gui/mainmenu.py @@ -815,7 +815,7 @@ class GuiMainMenu(QMenuBar): # Tools > Separator self.toolsMenu.addSeparator() - # Tools > Rebuild Indices + # Tools > Rebuild Index self.aRebuildIndex = QAction(self.tr("Rebuild Index"), self) self.aRebuildIndex.setShortcut("F9") self.aRebuildIndex.triggered.connect(lambda: self.mainGui.rebuildIndex()) @@ -824,24 +824,29 @@ class GuiMainMenu(QMenuBar): # Tools > Separator self.toolsMenu.addSeparator() - # Tools > Backup + # Tools > Backup Project self.aBackupProject = QAction(self.tr("Backup Project"), self) self.aBackupProject.triggered.connect(lambda: self.theProject.backupProject(True)) self.toolsMenu.addAction(self.aBackupProject) - # Tools > Export Project - self.aBuildProject = QAction(self.tr("Build Manuscript"), self) - self.aBuildProject.setShortcut("F5") - self.aBuildProject.triggered.connect(self.mainGui.showBuildManuscriptDialog) + # Tools > Build Project + self.aBuildProject = QAction(self.tr("Build Project"), self) + self.aBuildProject.triggered.connect(self.mainGui.showBuildProjectDialog) self.toolsMenu.addAction(self.aBuildProject) - # Tools > Writing Stats + # Tools > Build Manuscript + self.aBuildManuscript = QAction(self.tr("Build Manuscript"), self) + self.aBuildManuscript.setShortcut("F5") + self.aBuildManuscript.triggered.connect(self.mainGui.showBuildManuscriptDialog) + self.toolsMenu.addAction(self.aBuildManuscript) + + # Tools > Writing Statistics self.aWritingStats = QAction(self.tr("Writing Statistics"), self) self.aWritingStats.setShortcut("F6") self.aWritingStats.triggered.connect(lambda: self.mainGui.showWritingStatsDialog()) self.toolsMenu.addAction(self.aWritingStats) - # Tools > Settings + # Tools > Preferences self.aPreferences = QAction(self.tr("Preferences"), self) self.aPreferences.setShortcut("Ctrl+,") self.aPreferences.setMenuRole(QAction.PreferencesRole) From 8a4785d9a3b1cc874c574a9626cbe8407275b288 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 28 Feb 2023 22:39:09 +0100 Subject: [PATCH 11/74] Add build tool icons to theme --- .../assets/icons/typicons_dark/icons.conf | 3 +++ .../assets/icons/typicons_dark/typ_filter.svg | 16 ++++++++++++++++ .../assets/icons/typicons_light/icons.conf | 3 +++ .../assets/icons/typicons_light/typ_filter.svg | 16 ++++++++++++++++ novelwriter/gui/theme.py | 15 ++++++++------- 5 files changed, 46 insertions(+), 7 deletions(-) create mode 100644 novelwriter/assets/icons/typicons_dark/typ_filter.svg create mode 100644 novelwriter/assets/icons/typicons_light/typ_filter.svg diff --git a/novelwriter/assets/icons/typicons_dark/icons.conf b/novelwriter/assets/icons/typicons_dark/icons.conf index fa8b8619..e30b48f0 100644 --- a/novelwriter/assets/icons/typicons_dark/icons.conf +++ b/novelwriter/assets/icons/typicons_dark/icons.conf @@ -19,6 +19,9 @@ licenseurl = https://creativecommons.org/licenses/by-sa/4.0/ add = typ_plus.svg backward = typ_chevron-left.svg bookmark = typ_bookmark.svg +build_excluded = typ_cancel.svg +build_filtered = typ_filter.svg +build_included = typ_pin.svg bullet-off = typ_media-record-outline.svg bullet-on = typ_media-record.svg checked = mixed_input-checked.svg diff --git a/novelwriter/assets/icons/typicons_dark/typ_filter.svg b/novelwriter/assets/icons/typicons_dark/typ_filter.svg new file mode 100644 index 00000000..01e9c667 --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/typ_filter.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/novelwriter/assets/icons/typicons_light/icons.conf b/novelwriter/assets/icons/typicons_light/icons.conf index 5212b685..fde507ba 100644 --- a/novelwriter/assets/icons/typicons_light/icons.conf +++ b/novelwriter/assets/icons/typicons_light/icons.conf @@ -19,6 +19,9 @@ licenseurl = https://creativecommons.org/licenses/by-sa/4.0/ add = typ_plus.svg backward = typ_chevron-left.svg bookmark = typ_bookmark.svg +build_excluded = typ_cancel.svg +build_filtered = typ_filter.svg +build_included = typ_pin.svg bullet-off = typ_media-record-outline.svg bullet-on = typ_media-record.svg checked = mixed_input-checked.svg diff --git a/novelwriter/assets/icons/typicons_light/typ_filter.svg b/novelwriter/assets/icons/typicons_light/typ_filter.svg new file mode 100644 index 00000000..e7f86fba --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/typ_filter.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/novelwriter/gui/theme.py b/novelwriter/gui/theme.py index 746cde06..e7473ef0 100644 --- a/novelwriter/gui/theme.py +++ b/novelwriter/gui/theme.py @@ -445,13 +445,14 @@ class GuiIcons: ICON_KEYS = { # Project and GUI icons - "novelwriter", "cls_archive", "cls_character", "cls_custom", "cls_entity", "cls_none", - "cls_novel", "cls_object", "cls_plot", "cls_timeline", "cls_trash", "cls_world", - "proj_chapter", "proj_details", "proj_document", "proj_folder", "proj_note", "proj_nwx", - "proj_section", "proj_scene", "proj_stats", "proj_title", "search_cancel", "search_case", - "search_loop", "search_preserve", "search_project", "search_regex", "search_word", - "status_idle", "status_lang", "status_lines", "status_stats", "status_time", "view_build", - "view_editor", "view_novel", "view_outline", + "novelwriter", "build_excluded", "build_filtered", "build_included", "cls_archive", + "cls_character", "cls_custom", "cls_entity", "cls_none", "cls_novel", "cls_object", + "cls_plot", "cls_timeline", "cls_trash", "cls_world", "proj_chapter", "proj_details", + "proj_document", "proj_folder", "proj_note", "proj_nwx", "proj_section", "proj_scene", + "proj_stats", "proj_title", "search_cancel", "search_case", "search_loop", + "search_preserve", "search_project", "search_regex", "search_word", "status_idle", + "status_lang", "status_lines", "status_stats", "status_time", "view_build", "view_editor", + "view_novel", "view_outline", # General Button Icons "add", "backward", "bookmark", "checked", "close", "cross", "down", "edit", "forward", From cfd1b950d36bfab1bc91a73d9eeaf7571df1d7c2 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 28 Feb 2023 22:40:02 +0100 Subject: [PATCH 12/74] Rename item isInactive to isInactiveClass --- novelwriter/core/index.py | 2 +- novelwriter/core/item.py | 2 +- novelwriter/gui/projtree.py | 2 +- novelwriter/tools/build.py | 4 ++-- tests/test_core/test_core_item.py | 22 +++++++++++----------- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index deaea5c3..920eef22 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -273,7 +273,7 @@ class NWIndex: return False logger.debug("Indexing item with handle '%s'", tHandle) - if theItem.isInactive(): + if theItem.isInactiveClass(): self._scanInactive(theItem, theText) else: self._scanActive(tHandle, theItem, theText, itemTags) diff --git a/novelwriter/core/item.py b/novelwriter/core/item.py index e04d7c25..dfee50a3 100644 --- a/novelwriter/core/item.py +++ b/novelwriter/core/item.py @@ -294,7 +294,7 @@ class NWItem: """ return self._class in (nwItemClass.NOVEL, nwItemClass.ARCHIVE, nwItemClass.TRASH) - def isInactive(self): + def isInactiveClass(self): """Returns true if the item is in an inactive class. """ return self._class in (nwItemClass.NO_CLASS, nwItemClass.ARCHIVE, nwItemClass.TRASH) diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index df29903b..0d8c6bed 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -1404,7 +1404,7 @@ class GuiProjectTree(QTreeWidget): self.theProject.tree.updateItemData(mHandle) # Update the index - if nwItemS.isInactive(): + if nwItemS.isInactiveClass(): self.theProject.index.deleteHandle(mHandle) else: self.theProject.index.reIndexHandle(mHandle) diff --git a/novelwriter/tools/build.py b/novelwriter/tools/build.py index b90893b2..bc80c2bb 100644 --- a/novelwriter/tools/build.py +++ b/novelwriter/tools/build.py @@ -367,7 +367,7 @@ class GuiBuildNovel(QDialog): iRow = 0 self.rootSelection = {} for tHandle, nwItem in self.theProject.tree.iterRoots(None): - if not nwItem.isInactive(): + if not nwItem.isInactiveClass(): rootLabel = QLabel(nwItem.itemName) rootLabel.setWordWrap(True) @@ -823,7 +823,7 @@ class GuiBuildNovel(QDialog): isNone = not theItem.isFileType() isNone |= theItem.itemLayout == nwItemLayout.NO_LAYOUT - isNone |= theItem.isInactive() + isNone |= theItem.isInactiveClass() isNone |= theItem.itemParent is None isNote = theItem.isNoteLayout() isNovel = not isNone and not isNote diff --git a/tests/test_core/test_core_item.py b/tests/test_core/test_core_item.py index 892bf250..b388a548 100644 --- a/tests/test_core/test_core_item.py +++ b/tests/test_core/test_core_item.py @@ -340,67 +340,67 @@ def testCoreItem_ClassSetter(mockGUI): assert theItem.itemClass == nwItemClass.NO_CLASS assert theItem.isNovelLike() is False assert theItem.documentAllowed() is False - assert theItem.isInactive() is True + assert theItem.isInactiveClass() is True theItem.setClass("NOVEL") assert theItem.itemClass == nwItemClass.NOVEL assert theItem.isNovelLike() is True assert theItem.documentAllowed() is True - assert theItem.isInactive() is False + assert theItem.isInactiveClass() is False theItem.setClass("PLOT") assert theItem.itemClass == nwItemClass.PLOT assert theItem.isNovelLike() is False assert theItem.documentAllowed() is False - assert theItem.isInactive() is False + assert theItem.isInactiveClass() is False theItem.setClass("CHARACTER") assert theItem.itemClass == nwItemClass.CHARACTER assert theItem.isNovelLike() is False assert theItem.documentAllowed() is False - assert theItem.isInactive() is False + assert theItem.isInactiveClass() is False theItem.setClass("WORLD") assert theItem.itemClass == nwItemClass.WORLD assert theItem.isNovelLike() is False assert theItem.documentAllowed() is False - assert theItem.isInactive() is False + assert theItem.isInactiveClass() is False theItem.setClass("TIMELINE") assert theItem.itemClass == nwItemClass.TIMELINE assert theItem.isNovelLike() is False assert theItem.documentAllowed() is False - assert theItem.isInactive() is False + assert theItem.isInactiveClass() is False theItem.setClass("OBJECT") assert theItem.itemClass == nwItemClass.OBJECT assert theItem.isNovelLike() is False assert theItem.documentAllowed() is False - assert theItem.isInactive() is False + assert theItem.isInactiveClass() is False theItem.setClass("ENTITY") assert theItem.itemClass == nwItemClass.ENTITY assert theItem.isNovelLike() is False assert theItem.documentAllowed() is False - assert theItem.isInactive() is False + assert theItem.isInactiveClass() is False theItem.setClass("CUSTOM") assert theItem.itemClass == nwItemClass.CUSTOM assert theItem.isNovelLike() is False assert theItem.documentAllowed() is False - assert theItem.isInactive() is False + assert theItem.isInactiveClass() is False theItem.setClass("ARCHIVE") assert theItem.itemClass == nwItemClass.ARCHIVE assert theItem.isNovelLike() is True assert theItem.documentAllowed() is True - assert theItem.isInactive() is True + assert theItem.isInactiveClass() is True theItem.setClass("TRASH") assert theItem.itemClass == nwItemClass.TRASH assert theItem.isNovelLike() is False assert theItem.documentAllowed() is True - assert theItem.isInactive() is True + assert theItem.isInactiveClass() is True # Alternative theItem.setClass(nwItemClass.NOVEL) From 9af273bf8f1f3e7a540ba0d6209a3663246c1b72 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 28 Feb 2023 22:40:23 +0100 Subject: [PATCH 13/74] Add functionality to build project tree in build tool --- novelwriter/gui/mainmenu.py | 4 +- novelwriter/guimain.py | 2 + novelwriter/tools/mbuild.py | 127 +++++++++++++++++++++++++++++++++--- 3 files changed, 121 insertions(+), 12 deletions(-) diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py index 0bc63f80..c06e6bce 100644 --- a/novelwriter/gui/mainmenu.py +++ b/novelwriter/gui/mainmenu.py @@ -829,8 +829,8 @@ class GuiMainMenu(QMenuBar): self.aBackupProject.triggered.connect(lambda: self.theProject.backupProject(True)) self.toolsMenu.addAction(self.aBackupProject) - # Tools > Build Project - self.aBuildProject = QAction(self.tr("Build Project"), self) + # Tools > Build Novel Project + self.aBuildProject = QAction(self.tr("Build Novel Project"), self) self.aBuildProject.triggered.connect(self.mainGui.showBuildProjectDialog) self.toolsMenu.addAction(self.aBuildProject) diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 0a8094f6..97db3859 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -1031,6 +1031,8 @@ class GuiMain(QMainWindow): dlgBuild.raise_() qApp.processEvents() + dlgBuild.loadContent() + return True def showLoremIpsumDialog(self): diff --git a/novelwriter/tools/mbuild.py b/novelwriter/tools/mbuild.py index 0d3c29e3..9ce40e04 100644 --- a/novelwriter/tools/mbuild.py +++ b/novelwriter/tools/mbuild.py @@ -26,10 +26,11 @@ along with this program. If not, see . import logging import novelwriter -from PyQt5.QtCore import pyqtSlot +from PyQt5.QtGui import QIcon +from PyQt5.QtCore import QSize, Qt, pyqtSlot from PyQt5.QtWidgets import ( - QAbstractItemView, QDialog, QHBoxLayout, QStackedWidget, QTreeWidget, - QWidget + QAbstractItemView, QDialog, QHBoxLayout, QHeaderView, QStackedWidget, + QTreeWidget, QTreeWidgetItem, QWidget ) from novelwriter.extensions.pagedsidebar import NPagedSideBar @@ -119,6 +120,12 @@ class GuiBuildManuscript(QDialog): return + def loadContent(self): + """Populate the tool widgets. + """ + self.optTabSelect.populateTree() + return + ## # Private Slots ## @@ -148,19 +155,63 @@ class GuiBuildManuscript(QDialog): class GuiBuildFilterTab(QWidget): + C_NAME = 0 + C_ACTIVE = 1 + C_STATUS = 2 + + D_HANDLE = Qt.UserRole + + F_NONE = 0 + F_FILTERED = 1 + F_INCLUDED = 2 + F_EXCLUDED = 3 + def __init__(self, buildMain): super().__init__(parent=buildMain) + self.mainConf = novelwriter.CONFIG + self.mainGui = buildMain.mainGui + self.mainTheme = buildMain.mainGui.mainTheme + self.theProject = buildMain.mainGui.theProject + + self._treeMap = {} + + self._statusFlags = { + self.F_NONE: ("", QIcon()), + self.F_FILTERED: (self.tr("Filtered"), self.mainTheme.getIcon("build_filtered")), + self.F_INCLUDED: (self.tr("Included"), self.mainTheme.getIcon("build_included")), + self.F_EXCLUDED: (self.tr("Excluded"), self.mainTheme.getIcon("build_excluded")), + } + + # Widgets + # ======= + + # Tree Settings + iPx = self.mainTheme.baseIconSize + cMg = self.mainConf.pxInt(6) + + # Project Tree self.optTree = QTreeWidget(self) - self.optTree.setSelectionMode(QAbstractItemView.SingleSelection) - self.optTree.setDragDropMode(QAbstractItemView.NoDragDrop) - self.optTree.setColumnCount(2) - self.optTree.setHeaderLabels([ - self.tr("Setting"), - self.tr("Value"), - ]) + self.optTree.setIconSize(QSize(iPx, iPx)) + self.optTree.setUniformRowHeights(True) + self.optTree.setAllColumnsShowFocus(True) + self.optTree.setHeaderHidden(True) + self.optTree.setIndentation(iPx) + self.optTree.setColumnCount(3) self.optTree.setRootIsDecorated(False) + treeHeader = self.optTree.header() + treeHeader.setStretchLastSection(False) + treeHeader.setSectionResizeMode(self.C_NAME, QHeaderView.Stretch) + treeHeader.setSectionResizeMode(self.C_ACTIVE, QHeaderView.Fixed) + treeHeader.setSectionResizeMode(self.C_STATUS, QHeaderView.Fixed) + treeHeader.resizeSection(self.C_ACTIVE, iPx + cMg) + treeHeader.resizeSection(self.C_STATUS, iPx + cMg) + + self.optTree.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.optTree.setDragDropMode(QAbstractItemView.NoDragDrop) + + # Assemble self.outerBox = QHBoxLayout() self.outerBox.addWidget(self.optTree) @@ -168,6 +219,62 @@ class GuiBuildFilterTab(QWidget): return + def populateTree(self): + """Build the tree of project items. + """ + self._treeMap = {} + self.optTree.clear() + for nwItem in self.theProject.getProjectItems(): + + tHandle = nwItem.itemHandle + pHandle = nwItem.itemParent + + if nwItem.isInactiveClass(): + logger.debug("Skipping inactive class item '%s'", tHandle) + continue + + hLevel = nwItem.mainHeading + itemIcon = self.mainTheme.getItemIcon( + nwItem.itemType, nwItem.itemClass, nwItem.itemLayout, hLevel + ) + + if nwItem.isFileType(): + iconName = "checked" if nwItem.isActive else "unchecked" + statusIcon = self._statusFlags[ + self.F_FILTERED if nwItem.isActive else self.F_NONE + ][1] + else: + iconName = "noncheckable" + statusIcon = self._statusFlags[self.F_NONE][1] + + trItem = QTreeWidgetItem() + trItem.setIcon(self.C_NAME, itemIcon) + trItem.setText(self.C_NAME, nwItem.itemName) + trItem.setData(self.C_NAME, self.D_HANDLE, tHandle) + trItem.setIcon(self.C_ACTIVE, self.mainTheme.getIcon(iconName)) + trItem.setIcon(self.C_STATUS, statusIcon) + + trItem.setTextAlignment(self.C_NAME, Qt.AlignLeft) + + if pHandle is None: + if nwItem.isRootType(): + self.optTree.addTopLevelItem(trItem) + else: + logger.debug("Skipping item '%s'", tHandle) + continue + + elif pHandle in self._treeMap: + self._treeMap[pHandle].addChild(trItem) + + else: + logger.debug("Skipping item '%s'", tHandle) + continue + + self._treeMap[tHandle] = trItem + trItem.setExpanded(True) + + return + # END Class GuiBuildFilterTab From 5639df7efd31580a716324ca1933078e3f512447 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 28 Feb 2023 23:19:57 +0100 Subject: [PATCH 14/74] Add buttons to set filter mode on build items --- novelwriter/core/buildsettings.py | 30 +++++++++ novelwriter/tools/mbuild.py | 105 ++++++++++++++++++++++++++---- 2 files changed, 122 insertions(+), 13 deletions(-) diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index 6f492862..db2895e1 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -98,10 +98,40 @@ class BuildSettings: self._data = {} + self._excluded = set() + self._included = set() + self._loadTemplate() return + def isIncluded(self, tHandle): + return tHandle in self._included + + def isExcluded(self, tHandle): + return tHandle in self._excluded + + def setFiltered(self, tHandle): + """Set an item as filtered. + """ + self._excluded.discard(tHandle) + self._included.discard(tHandle) + return + + def setIncluded(self, tHandle): + """Set an item as explicitly included. + """ + self._excluded.discard(tHandle) + self._included.add(tHandle) + return + + def setExcluded(self, tHandle): + """Set an item as explicitly excluded. + """ + self._excluded.add(tHandle) + self._included.discard(tHandle) + return + ## # Internal Functions ## diff --git a/novelwriter/tools/mbuild.py b/novelwriter/tools/mbuild.py index 9ce40e04..39c2d20e 100644 --- a/novelwriter/tools/mbuild.py +++ b/novelwriter/tools/mbuild.py @@ -29,10 +29,11 @@ import novelwriter from PyQt5.QtGui import QIcon from PyQt5.QtCore import QSize, Qt, pyqtSlot from PyQt5.QtWidgets import ( - QAbstractItemView, QDialog, QHBoxLayout, QHeaderView, QStackedWidget, - QTreeWidget, QTreeWidgetItem, QWidget + QAbstractItemView, QDialog, QHBoxLayout, QHeaderView, QPushButton, QStackedWidget, + QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget ) +from novelwriter.core.buildsettings import BuildSettings from novelwriter.extensions.pagedsidebar import NPagedSideBar logger = logging.getLogger(__name__) @@ -56,13 +57,17 @@ class GuiBuildManuscript(QDialog): self.mainTheme = mainGui.mainTheme self.theProject = mainGui.theProject + self.buildOpts = { + "name": self.tr("Default Settings"), + "settings": BuildSettings(), + } + self.setWindowTitle(self.tr("Build Manuscript")) self.setMinimumWidth(self.mainConf.pxInt(700)) self.setMinimumHeight(self.mainConf.pxInt(600)) # Style mPx = self.mainConf.pxInt(150) - # tPx = int(self.mainTheme.fontPixelSize * 1.5) # Options SideBar # =============== @@ -155,11 +160,13 @@ class GuiBuildManuscript(QDialog): class GuiBuildFilterTab(QWidget): + C_DATA = 0 C_NAME = 0 C_ACTIVE = 1 C_STATUS = 2 D_HANDLE = Qt.UserRole + D_FILE = Qt.UserRole + 1 F_NONE = 0 F_FILTERED = 1 @@ -173,6 +180,7 @@ class GuiBuildFilterTab(QWidget): self.mainGui = buildMain.mainGui self.mainTheme = buildMain.mainGui.mainTheme self.theProject = buildMain.mainGui.theProject + self.buildOpts = buildMain.buildOpts self._treeMap = {} @@ -183,14 +191,14 @@ class GuiBuildFilterTab(QWidget): self.F_EXCLUDED: (self.tr("Excluded"), self.mainTheme.getIcon("build_excluded")), } - # Widgets - # ======= + # Project Tree + # ============ # Tree Settings iPx = self.mainTheme.baseIconSize cMg = self.mainConf.pxInt(6) - # Project Tree + # Tree Widget self.optTree = QTreeWidget(self) self.optTree.setIconSize(QSize(iPx, iPx)) self.optTree.setUniformRowHeights(True) @@ -211,9 +219,37 @@ class GuiBuildFilterTab(QWidget): self.optTree.setSelectionMode(QAbstractItemView.ExtendedSelection) self.optTree.setDragDropMode(QAbstractItemView.NoDragDrop) + # Filters + # ======= + + self.filteredButton = QPushButton(self) + self.filteredButton.setText(self._statusFlags[self.F_FILTERED][0]) + self.filteredButton.setIcon(self._statusFlags[self.F_FILTERED][1]) + self.filteredButton.clicked.connect(lambda: self._setSelectedMode(self.F_FILTERED)) + + self.includedButton = QPushButton(self) + self.includedButton.setText(self._statusFlags[self.F_INCLUDED][0]) + self.includedButton.setIcon(self._statusFlags[self.F_INCLUDED][1]) + self.includedButton.clicked.connect(lambda: self._setSelectedMode(self.F_INCLUDED)) + + self.excludedButton = QPushButton(self) + self.excludedButton.setText(self._statusFlags[self.F_EXCLUDED][0]) + self.excludedButton.setIcon(self._statusFlags[self.F_EXCLUDED][1]) + self.excludedButton.clicked.connect(lambda: self._setSelectedMode(self.F_EXCLUDED)) + + self.modeBox = QHBoxLayout() + self.modeBox.addWidget(self.filteredButton) + self.modeBox.addWidget(self.includedButton) + self.modeBox.addWidget(self.excludedButton) + # Assemble + self.selectionBox = QVBoxLayout() + self.selectionBox.addLayout(self.modeBox) + self.selectionBox.addStretch() + self.outerBox = QHBoxLayout() self.outerBox.addWidget(self.optTree) + self.outerBox.addLayout(self.selectionBox) self.setLayout(self.outerBox) @@ -228,6 +264,7 @@ class GuiBuildFilterTab(QWidget): tHandle = nwItem.itemHandle pHandle = nwItem.itemParent + isFile = nwItem.isFileType() if nwItem.isInactiveClass(): logger.debug("Skipping inactive class item '%s'", tHandle) @@ -238,21 +275,17 @@ class GuiBuildFilterTab(QWidget): nwItem.itemType, nwItem.itemClass, nwItem.itemLayout, hLevel ) - if nwItem.isFileType(): + if isFile: iconName = "checked" if nwItem.isActive else "unchecked" - statusIcon = self._statusFlags[ - self.F_FILTERED if nwItem.isActive else self.F_NONE - ][1] else: iconName = "noncheckable" - statusIcon = self._statusFlags[self.F_NONE][1] trItem = QTreeWidgetItem() trItem.setIcon(self.C_NAME, itemIcon) trItem.setText(self.C_NAME, nwItem.itemName) - trItem.setData(self.C_NAME, self.D_HANDLE, tHandle) + trItem.setData(self.C_DATA, self.D_HANDLE, tHandle) + trItem.setData(self.C_DATA, self.D_FILE, isFile) trItem.setIcon(self.C_ACTIVE, self.mainTheme.getIcon(iconName)) - trItem.setIcon(self.C_STATUS, statusIcon) trItem.setTextAlignment(self.C_NAME, Qt.AlignLeft) @@ -273,6 +306,52 @@ class GuiBuildFilterTab(QWidget): self._treeMap[tHandle] = trItem trItem.setExpanded(True) + self._setTreeItemMode() + + return + + ## + # Internal Functions + ## + + def _setSelectedMode(self, mode): + """Set the mode for the selected items. + """ + buildSettings = self.buildOpts["settings"] + + for item in self.optTree.selectedItems(): + if not isinstance(item, QTreeWidgetItem): + continue + + tHandle = item.data(self.C_DATA, self.D_HANDLE) + isFile = item.data(self.C_DATA, self.D_FILE) + if isFile: + if mode == self.F_FILTERED: + buildSettings.setFiltered(tHandle) + elif mode == self.F_INCLUDED: + buildSettings.setIncluded(tHandle) + elif mode == self.F_EXCLUDED: + buildSettings.setExcluded(tHandle) + + self._setTreeItemMode() + + return + + def _setTreeItemMode(self): + """Update the filtered mode icon on all items. + """ + buildSettings = self.buildOpts["settings"] + for tHandle, item in self._treeMap.items(): + if item.data(self.C_DATA, self.D_FILE): + if buildSettings.isIncluded(tHandle): + item.setIcon(self.C_STATUS, self._statusFlags[self.F_INCLUDED][1]) + elif buildSettings.isExcluded(tHandle): + item.setIcon(self.C_STATUS, self._statusFlags[self.F_EXCLUDED][1]) + else: + item.setIcon(self.C_STATUS, self._statusFlags[self.F_FILTERED][1]) + else: + item.setIcon(self.C_STATUS, self._statusFlags[self.F_NONE][1]) + return # END Class GuiBuildFilterTab From 29386e8595d342a37bcf44cdc80a9923188916c1 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 16 Apr 2023 21:19:09 +0200 Subject: [PATCH 15/74] Add a switch box widget and use it for filter options in build --- novelwriter/extensions/configlayout.py | 3 - novelwriter/extensions/pageddialog.py | 3 - novelwriter/extensions/switch.py | 3 - novelwriter/extensions/switchbox.py | 119 +++++++++++++++++++++++++ novelwriter/tools/mbuild.py | 26 +++++- 5 files changed, 142 insertions(+), 12 deletions(-) create mode 100644 novelwriter/extensions/switchbox.py diff --git a/novelwriter/extensions/configlayout.py b/novelwriter/extensions/configlayout.py index 8eadae62..129400e2 100644 --- a/novelwriter/extensions/configlayout.py +++ b/novelwriter/extensions/configlayout.py @@ -23,7 +23,6 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import logging import novelwriter from PyQt5.QtGui import QColor, QPalette @@ -33,8 +32,6 @@ from PyQt5.QtWidgets import ( QWidget ) -logger = logging.getLogger(__name__) - class NConfigLayout(QGridLayout): diff --git a/novelwriter/extensions/pageddialog.py b/novelwriter/extensions/pageddialog.py index 47f957fb..ec22767b 100644 --- a/novelwriter/extensions/pageddialog.py +++ b/novelwriter/extensions/pageddialog.py @@ -23,7 +23,6 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import logging import novelwriter from PyQt5.QtCore import QRect, QPoint @@ -32,8 +31,6 @@ from PyQt5.QtWidgets import ( QTabWidget, QVBoxLayout ) -logger = logging.getLogger(__name__) - class NPagedDialog(QDialog): diff --git a/novelwriter/extensions/switch.py b/novelwriter/extensions/switch.py index d57f07b2..ab1efa9a 100644 --- a/novelwriter/extensions/switch.py +++ b/novelwriter/extensions/switch.py @@ -23,7 +23,6 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import logging import novelwriter from PyQt5.QtGui import QPainter @@ -32,8 +31,6 @@ from PyQt5.QtWidgets import QSizePolicy, QAbstractButton from novelwriter.constants import nwUnicode -logger = logging.getLogger(__name__) - class NSwitch(QAbstractButton): diff --git a/novelwriter/extensions/switchbox.py b/novelwriter/extensions/switchbox.py new file mode 100644 index 00000000..45d7680f --- /dev/null +++ b/novelwriter/extensions/switchbox.py @@ -0,0 +1,119 @@ +""" +novelWriter – Custom Widget: Switch Box +======================================= +A box of icons, labels and switches + +File History: +Created: 2023-04-16 [2.1b1] + +This file is a part of novelWriter +Copyright 2018–2023, 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 . +""" + +import novelwriter + +from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtWidgets import QGridLayout, QLabel, QScrollArea, QSizePolicy, QWidget + +from novelwriter.extensions.switch import NSwitch + + +class NSwitchBox(QScrollArea): + + switchToggled = pyqtSignal(str, bool) + + def __init__(self, parent, baseSize): + super().__init__(parent=parent) + + self.mainConf = novelwriter.CONFIG + + self._index = 0 + self._hSwitch = baseSize + self._wSwitch = 2*self._hSwitch + self._sIcon = int(round(0.8*baseSize)) + + self._content = QGridLayout() + self._content.setColumnStretch(1, 1) + + self._widget = QWidget() + self._widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum) + self._widget.setLayout(self._content) + + self.setWidgetResizable(True) + self.setWidget(self._widget) + + return + + def addLabel(self, text): + """Add a header label to the content box. + """ + label = QLabel(text) + font = label.font() + font.setBold(True) + label.setFont(font) + self._content.addWidget(label, self._index, 0, 1, 3, Qt.AlignLeft) + self._bumpIndex() + return + + def addItem(self, qIcon, text, identifier, default=False): + """Add an item to the content box. + """ + icon = QLabel("") + icon.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + icon.setPixmap(qIcon.pixmap(self._sIcon, self._sIcon)) + self._content.addWidget(icon, self._index, 0, Qt.AlignLeft) + + label = QLabel(text) + self._content.addWidget(label, self._index, 1, Qt.AlignLeft) + + switch = NSwitch(width=self._wSwitch, height=self._hSwitch) + switch.setChecked(default) + switch.toggled.connect(lambda state: self._emitSwitchSignal(identifier, state)) + self._content.addWidget(switch, self._index, 2, Qt.AlignRight) + + self._bumpIndex() + + return + + def addSeparator(self): + """Add a blank entry in the content box. + """ + spacer = QWidget() + spacer.setFixedHeight(int(0.5*self._sIcon)) + self._content.addWidget(spacer, self._index, 0, 1, 3, Qt.AlignLeft) + self._bumpIndex() + return + + ## + # Internal Functions + ## + + def _emitSwitchSignal(self, identifier, state): + """Emit a signal for a switch toggle. + """ + self.switchToggled.emit(identifier, state) + return + + def _bumpIndex(self): + """Increase the index counter and make sure only the last + columns is stretcing. + """ + self._content.setRowStretch(self._index, 0) + self._content.setRowStretch(self._index + 1, 1) + self._index += 1 + return + +# END Class NSwitchBox diff --git a/novelwriter/tools/mbuild.py b/novelwriter/tools/mbuild.py index 39c2d20e..11cabb0e 100644 --- a/novelwriter/tools/mbuild.py +++ b/novelwriter/tools/mbuild.py @@ -24,16 +24,18 @@ along with this program. If not, see . """ import logging + import novelwriter from PyQt5.QtGui import QIcon from PyQt5.QtCore import QSize, Qt, pyqtSlot from PyQt5.QtWidgets import ( - QAbstractItemView, QDialog, QHBoxLayout, QHeaderView, QPushButton, QStackedWidget, - QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget + QAbstractItemView, QDialog, QHBoxLayout, QHeaderView, QPushButton, + QStackedWidget, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget ) from novelwriter.core.buildsettings import BuildSettings +from novelwriter.extensions.switchbox import NSwitchBox from novelwriter.extensions.pagedsidebar import NPagedSideBar logger = logging.getLogger(__name__) @@ -242,10 +244,28 @@ class GuiBuildFilterTab(QWidget): self.modeBox.addWidget(self.includedButton) self.modeBox.addWidget(self.excludedButton) + # Filer Options + self.filterOpt = NSwitchBox(self, iPx) + + self.filterOpt.addLabel(self.tr("Document Types")) + self.filterOpt.addItem(QIcon(), "Novel Documents", "doc:novel") + self.filterOpt.addItem(QIcon(), "Project Notes", "doc:notes") + self.filterOpt.addItem(QIcon(), "Inactive Documents", "doc:inactive") + self.filterOpt.addSeparator() + + # Root Classes + self.filterOpt.addLabel(self.tr("Root Folders")) + for tHandle, nwItem in self.theProject.tree.iterRoots(None): + if not nwItem.isInactiveClass(): + itemIcon = self.mainTheme.getItemIcon( + nwItem.itemType, nwItem.itemClass, nwItem.itemLayout + ) + self.filterOpt.addItem(itemIcon, nwItem.itemName, f"root:{tHandle}") + # Assemble self.selectionBox = QVBoxLayout() self.selectionBox.addLayout(self.modeBox) - self.selectionBox.addStretch() + self.selectionBox.addWidget(self.filterOpt) self.outerBox = QHBoxLayout() self.outerBox.addWidget(self.optTree) From 46b4e39bf51750ad8509897b57d3851a55e6c0ae Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 7 May 2023 19:21:07 +0200 Subject: [PATCH 16/74] Add code to filter items in build tool based on layout and active status --- novelwriter/core/buildsettings.py | 124 ++++++++++++++++++++++------ novelwriter/extensions/switchbox.py | 7 ++ novelwriter/tools/mbuild.py | 87 +++++++++++++------ 3 files changed, 166 insertions(+), 52 deletions(-) diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index db2895e1..acd2c921 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -25,8 +25,13 @@ along with this program. If not, see . import logging +from enum import Enum + from PyQt5.QtCore import QT_TRANSLATE_NOOP +from novelwriter.core.item import NWItem +from novelwriter.core.project import NWProject + logger = logging.getLogger(__name__) # The Settings Template @@ -35,6 +40,9 @@ logger = logging.getLogger(__name__) # (type, default, [min value, max value]) SETTINGS_TEMPLATE = { + "filter.includeNovel": (bool, True), + "filter.includeNotes": (bool, False), + "filter.includeInactive": (bool, False), "headings.fmtTitle": (str, "%title%"), "headings.fmtChapter": (str, "%title%"), "headings.fmtUnnumbered": (str, "%title%"), @@ -42,6 +50,10 @@ SETTINGS_TEMPLATE = { "headings.fmtSection": (str, "%title%"), "headings.hideScene": (bool, False), "headings.hideSection": (bool, False), + "text.includeSynopsis": (bool, False), + "text.includeComments": (bool, False), + "text.includeKeywords": (bool, False), + "text.includeBody": (bool, True), "format.buildLang": (str, "en_GB"), "format.textFont": (str, ""), "format.textSize": (str, ""), @@ -52,14 +64,12 @@ SETTINGS_TEMPLATE = { "html.addStyles": (bool, False), } -FILTER_TEMPLATE = { - "filter.includeSynopsis": (bool, False), - "filter.includeComments": (bool, False), - "filter.includeKeywords": (bool, False), - "filter.includeBody": (bool, True), -} - SETTINGS_LABELS = { + "filter": QT_TRANSLATE_NOOP("Builds", "Document Types"), + "filter.includeNovel": QT_TRANSLATE_NOOP("Builds", "Novel Documents"), + "filter.includeNotes": QT_TRANSLATE_NOOP("Builds", "Project Notes"), + "filter.includeInactive": QT_TRANSLATE_NOOP("Builds", "Inactive Documents"), + "headings": QT_TRANSLATE_NOOP("Builds", "Headings"), "headings.fmtTitle": QT_TRANSLATE_NOOP("Builds", "Title Heading"), "headings.fmtChapter": QT_TRANSLATE_NOOP("Builds", "Chapter Heading"), @@ -69,6 +79,12 @@ SETTINGS_LABELS = { "headings.hideScene": QT_TRANSLATE_NOOP("Builds", "Hide Scene"), "headings.hideSection": QT_TRANSLATE_NOOP("Builds", "Hide Section"), + "text": QT_TRANSLATE_NOOP("Builds", "Text Content"), + "text.includeSynopsis": QT_TRANSLATE_NOOP("Builds", "Synopsis"), + "text.includeComments": QT_TRANSLATE_NOOP("Builds", "Comments"), + "text.includeKeywords": QT_TRANSLATE_NOOP("Builds", "Keywords"), + "text.includeBody": QT_TRANSLATE_NOOP("Builds", "Body Text"), + "format": QT_TRANSLATE_NOOP("Builds", "Text Format"), "format.buildLang": QT_TRANSLATE_NOOP("Builds", "Build Language"), "format.textFont": QT_TRANSLATE_NOOP("Builds", "Font Family"), @@ -84,27 +100,28 @@ SETTINGS_LABELS = { "html.addStyles": QT_TRANSLATE_NOOP("Builds", "Add CSS Styles"), } -FILTER_LABELS = { - "filter.includeSynopsis": QT_TRANSLATE_NOOP("Builds", "Synopsis"), - "filter.includeComments": QT_TRANSLATE_NOOP("Builds", "Comments"), - "filter.includeKeywords": QT_TRANSLATE_NOOP("Builds", "Keywords"), - "filter.includeBody": QT_TRANSLATE_NOOP("Builds", "Body Text"), -} + +class FilterMode(Enum): + + UNKNOWN = 0 + FILTERED = 1 + INCLUDED = 2 + EXCLUDED = 3 + +# END Enum FilterMode class BuildSettings: def __init__(self): - - self._data = {} - self._excluded = set() self._included = set() - - self._loadTemplate() - + self._settings = {k: v[1] for k, v in SETTINGS_TEMPLATE.items()} return + def isFiltered(self, tHandle): + return tHandle not in self._included and tHandle not in self._excluded + def isIncluded(self, tHandle): return tHandle in self._included @@ -132,13 +149,68 @@ class BuildSettings: self._included.discard(tHandle) return - ## - # Internal Functions - ## - - def _loadTemplate(self): - """Populate the data dictionary from the template. + def setValue(self, key, value): + """Set a specific value for a build setting. """ - return + if key not in SETTINGS_TEMPLATE: + return False + definition = SETTINGS_TEMPLATE[key] + if not isinstance(value, definition[0]): + return False + if len(definition) == 4: + value = min(max(value, definition[2]), definition[3]) + self._settings[key] = value + logger.debug(f"Build Setting '{key}' set to: {value}") + return True + + def getLabel(self, key): + """Extract the label for a specific item. + """ + return SETTINGS_LABELS.get(key, "ERROR") + + def getValue(self, key): + """Get the value for a specific item, or return the default. + """ + return self._settings.get(key, SETTINGS_TEMPLATE.get(key, (None, None)[1])) + + def checkItemFilter(self, project): + """Return a dictionary of item handles with filter decissions + applied. + """ + result = {} + if not isinstance(project, NWProject): + return result + + incNovel = self.getValue("filter.includeNovel") or False + incNotes = self.getValue("filter.includeNotes") or False + incInactive = self.getValue("filter.includeInactive") or False + + for item in project.tree: + tHandle = item.itemHandle + if not isinstance(item, NWItem): + result[tHandle] = (False, FilterMode.UNKNOWN) + continue + if not item.isFileType(): + result[tHandle] = (False, FilterMode.FILTERED) + continue + if tHandle in self._included: + result[tHandle] = (True, FilterMode.INCLUDED) + continue + if tHandle in self._excluded: + result[tHandle] = (False, FilterMode.EXCLUDED) + continue + + isNote = item.isNoteLayout() + isNovel = item.isDocumentLayout() + isActive = item.isActive + + byActive = isActive or (not isActive and incInactive) + byLayout = (isNote and incNotes) or (isNovel and incNovel) + + isAllowed = byActive and byLayout + + result[tHandle] = (isAllowed, FilterMode.FILTERED) + + return result # END Class BuildSettings diff --git a/novelwriter/extensions/switchbox.py b/novelwriter/extensions/switchbox.py index 45d7680f..57e43f7e 100644 --- a/novelwriter/extensions/switchbox.py +++ b/novelwriter/extensions/switchbox.py @@ -45,6 +45,13 @@ class NSwitchBox(QScrollArea): self._wSwitch = 2*self._hSwitch self._sIcon = int(round(0.8*baseSize)) + self.clear() + + return + + def clear(self): + """Rebuild the content of the core widget. + """ self._content = QGridLayout() self._content.setColumnStretch(1, 1) diff --git a/novelwriter/tools/mbuild.py b/novelwriter/tools/mbuild.py index 11cabb0e..8eed17bb 100644 --- a/novelwriter/tools/mbuild.py +++ b/novelwriter/tools/mbuild.py @@ -34,7 +34,7 @@ from PyQt5.QtWidgets import ( QStackedWidget, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget ) -from novelwriter.core.buildsettings import BuildSettings +from novelwriter.core.buildsettings import BuildSettings, FilterMode from novelwriter.extensions.switchbox import NSwitchBox from novelwriter.extensions.pagedsidebar import NPagedSideBar @@ -62,6 +62,7 @@ class GuiBuildManuscript(QDialog): self.buildOpts = { "name": self.tr("Default Settings"), "settings": BuildSettings(), + "filter": {}, } self.setWindowTitle(self.tr("Build Manuscript")) @@ -131,6 +132,7 @@ class GuiBuildManuscript(QDialog): """Populate the tool widgets. """ self.optTabSelect.populateTree() + self.optTabSelect.populateFilters() return ## @@ -168,7 +170,10 @@ class GuiBuildFilterTab(QWidget): C_STATUS = 2 D_HANDLE = Qt.UserRole - D_FILE = Qt.UserRole + 1 + D_FILTER = Qt.UserRole + 1 + D_FILE = Qt.UserRole + 2 + D_NOVEL = Qt.UserRole + 3 + D_ACTIVE = Qt.UserRole + 4 F_NONE = 0 F_FILTERED = 1 @@ -246,21 +251,7 @@ class GuiBuildFilterTab(QWidget): # Filer Options self.filterOpt = NSwitchBox(self, iPx) - - self.filterOpt.addLabel(self.tr("Document Types")) - self.filterOpt.addItem(QIcon(), "Novel Documents", "doc:novel") - self.filterOpt.addItem(QIcon(), "Project Notes", "doc:notes") - self.filterOpt.addItem(QIcon(), "Inactive Documents", "doc:inactive") - self.filterOpt.addSeparator() - - # Root Classes - self.filterOpt.addLabel(self.tr("Root Folders")) - for tHandle, nwItem in self.theProject.tree.iterRoots(None): - if not nwItem.isInactiveClass(): - itemIcon = self.mainTheme.getItemIcon( - nwItem.itemType, nwItem.itemClass, nwItem.itemLayout - ) - self.filterOpt.addItem(itemIcon, nwItem.itemName, f"root:{tHandle}") + self.filterOpt.switchToggled.connect(self._applyFilterSwitch) # Assemble self.selectionBox = QVBoxLayout() @@ -285,6 +276,8 @@ class GuiBuildFilterTab(QWidget): tHandle = nwItem.itemHandle pHandle = nwItem.itemParent isFile = nwItem.isFileType() + isNovel = nwItem.isNovelLike() + isActive = nwItem.isActive if nwItem.isInactiveClass(): logger.debug("Skipping inactive class item '%s'", tHandle) @@ -296,7 +289,7 @@ class GuiBuildFilterTab(QWidget): ) if isFile: - iconName = "checked" if nwItem.isActive else "unchecked" + iconName = "checked" if isActive else "unchecked" else: iconName = "noncheckable" @@ -304,7 +297,10 @@ class GuiBuildFilterTab(QWidget): trItem.setIcon(self.C_NAME, itemIcon) trItem.setText(self.C_NAME, nwItem.itemName) trItem.setData(self.C_DATA, self.D_HANDLE, tHandle) + trItem.setData(self.C_DATA, self.D_FILTER, False) trItem.setData(self.C_DATA, self.D_FILE, isFile) + trItem.setData(self.C_DATA, self.D_NOVEL, isNovel) + trItem.setData(self.C_DATA, self.D_ACTIVE, isActive) trItem.setIcon(self.C_ACTIVE, self.mainTheme.getIcon(iconName)) trItem.setTextAlignment(self.C_NAME, Qt.AlignLeft) @@ -330,6 +326,45 @@ class GuiBuildFilterTab(QWidget): return + def populateFilters(self): + """Populate the filter options switches. + """ + self.filterOpt.clear() + buildSettings = self.buildOpts["settings"] + + self.filterOpt.addLabel(buildSettings.getLabel("filter")) + for key in ["filter.includeNovel", "filter.includeNotes", "filter.includeInactive"]: + label = buildSettings.getLabel(key) + value = buildSettings.getValue(key) + if isinstance(value, bool): + self.filterOpt.addItem(QIcon(), label, f"doc:{key}", default=value) + + self.filterOpt.addSeparator() + + # Root Classes + self.filterOpt.addLabel(self.tr("Root Folders")) + for tHandle, nwItem in self.theProject.tree.iterRoots(None): + if not nwItem.isInactiveClass(): + itemIcon = self.mainTheme.getItemIcon( + nwItem.itemType, nwItem.itemClass, nwItem.itemLayout + ) + self.filterOpt.addItem(itemIcon, nwItem.itemName, f"root:{tHandle}", default=True) + + return + + ## + # Slots + ## + + @pyqtSlot(str, bool) + def _applyFilterSwitch(self, key, state): + """A filter switch has been toggled, so update the settings. + """ + if key.startswith("doc:"): + self.buildOpts["settings"].setValue(key[4:], state) + self._setTreeItemMode() + return + ## # Internal Functions ## @@ -360,15 +395,15 @@ class GuiBuildFilterTab(QWidget): def _setTreeItemMode(self): """Update the filtered mode icon on all items. """ - buildSettings = self.buildOpts["settings"] + filtered = self.buildOpts["settings"].checkItemFilter(self.theProject) for tHandle, item in self._treeMap.items(): - if item.data(self.C_DATA, self.D_FILE): - if buildSettings.isIncluded(tHandle): - item.setIcon(self.C_STATUS, self._statusFlags[self.F_INCLUDED][1]) - elif buildSettings.isExcluded(tHandle): - item.setIcon(self.C_STATUS, self._statusFlags[self.F_EXCLUDED][1]) - else: - item.setIcon(self.C_STATUS, self._statusFlags[self.F_FILTERED][1]) + allow, mode = filtered.get(tHandle, (False, FilterMode.UNKNOWN)) + if mode == FilterMode.INCLUDED: + item.setIcon(self.C_STATUS, self._statusFlags[self.F_INCLUDED][1]) + elif mode == FilterMode.EXCLUDED: + item.setIcon(self.C_STATUS, self._statusFlags[self.F_EXCLUDED][1]) + elif mode == FilterMode.FILTERED and allow: + item.setIcon(self.C_STATUS, self._statusFlags[self.F_FILTERED][1]) else: item.setIcon(self.C_STATUS, self._statusFlags[self.F_NONE][1]) From 4c4139078adab5485ecfd50d58dca58ab0993972 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 7 May 2023 19:36:52 +0200 Subject: [PATCH 17/74] Add icons to document filters --- novelwriter/extensions/switchbox.py | 2 +- novelwriter/gui/theme.py | 2 +- novelwriter/tools/mbuild.py | 23 ++++++++++++++++++----- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/novelwriter/extensions/switchbox.py b/novelwriter/extensions/switchbox.py index 57e43f7e..d036ccb6 100644 --- a/novelwriter/extensions/switchbox.py +++ b/novelwriter/extensions/switchbox.py @@ -43,7 +43,7 @@ class NSwitchBox(QScrollArea): self._index = 0 self._hSwitch = baseSize self._wSwitch = 2*self._hSwitch - self._sIcon = int(round(0.8*baseSize)) + self._sIcon = baseSize self.clear() diff --git a/novelwriter/gui/theme.py b/novelwriter/gui/theme.py index e7473ef0..bfb6c3de 100644 --- a/novelwriter/gui/theme.py +++ b/novelwriter/gui/theme.py @@ -444,7 +444,7 @@ class GuiIcons: """ ICON_KEYS = { - # Project and GUI icons + # Project and GUI Icons "novelwriter", "build_excluded", "build_filtered", "build_included", "cls_archive", "cls_character", "cls_custom", "cls_entity", "cls_none", "cls_novel", "cls_object", "cls_plot", "cls_timeline", "cls_trash", "cls_world", "proj_chapter", "proj_details", diff --git a/novelwriter/tools/mbuild.py b/novelwriter/tools/mbuild.py index 8eed17bb..f9a1de6b 100644 --- a/novelwriter/tools/mbuild.py +++ b/novelwriter/tools/mbuild.py @@ -333,11 +333,24 @@ class GuiBuildFilterTab(QWidget): buildSettings = self.buildOpts["settings"] self.filterOpt.addLabel(buildSettings.getLabel("filter")) - for key in ["filter.includeNovel", "filter.includeNotes", "filter.includeInactive"]: - label = buildSettings.getLabel(key) - value = buildSettings.getValue(key) - if isinstance(value, bool): - self.filterOpt.addItem(QIcon(), label, f"doc:{key}", default=value) + self.filterOpt.addItem( + self.mainTheme.getIcon("proj_scene"), + buildSettings.getLabel("filter.includeNovel"), + "doc:filter.includeNovel", + default=buildSettings.getValue("filter.includeNovel") or False + ) + self.filterOpt.addItem( + self.mainTheme.getIcon("proj_note"), + buildSettings.getLabel("filter.includeNotes"), + "doc:filter.includeNotes", + default=buildSettings.getValue("filter.includeNotes") or False + ) + self.filterOpt.addItem( + self.mainTheme.getIcon("unchecked"), + buildSettings.getLabel("filter.includeInactive"), + "doc:filter.includeInactive", + default=buildSettings.getValue("filter.includeInactive") or False + ) self.filterOpt.addSeparator() From a9d59743469fd31c13ff722c0fc2188b82af2e08 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Tue, 9 May 2023 22:00:30 +0200 Subject: [PATCH 18/74] Add root class filtering --- novelwriter/core/buildsettings.py | 21 +++++++++++++-- novelwriter/tools/mbuild.py | 44 ++++++++++++++----------------- 2 files changed, 39 insertions(+), 26 deletions(-) diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index acd2c921..787101eb 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -103,10 +103,11 @@ SETTINGS_LABELS = { class FilterMode(Enum): - UNKNOWN = 0 + UNKNOWN = 0 FILTERED = 1 INCLUDED = 2 EXCLUDED = 3 + SKIPPED = 4 # END Enum FilterMode @@ -114,6 +115,7 @@ class FilterMode(Enum): class BuildSettings: def __init__(self): + self._skiproot = set() self._excluded = set() self._included = set() self._settings = {k: v[1] for k, v in SETTINGS_TEMPLATE.items()} @@ -128,6 +130,9 @@ class BuildSettings: def isExcluded(self, tHandle): return tHandle in self._excluded + def isRootAllowed(self, tHandle): + return tHandle not in self._skiproot + def setFiltered(self, tHandle): """Set an item as filtered. """ @@ -149,6 +154,15 @@ class BuildSettings: self._included.discard(tHandle) return + def setSkipRoot(self, tHandle, state): + """Set a specific root folder as skipped or not. + """ + if state is True: + self._skiproot.discard(tHandle) + elif state is False: + self._skiproot.add(tHandle) + return + def setValue(self, key, value): """Set a specific value for a build setting. """ @@ -190,8 +204,11 @@ class BuildSettings: if not isinstance(item, NWItem): result[tHandle] = (False, FilterMode.UNKNOWN) continue + if item.isInactiveClass() or (item.itemRoot in self._skiproot): + result[tHandle] = (False, FilterMode.SKIPPED) + continue if not item.isFileType(): - result[tHandle] = (False, FilterMode.FILTERED) + result[tHandle] = (False, FilterMode.SKIPPED) continue if tHandle in self._included: result[tHandle] = (True, FilterMode.INCLUDED) diff --git a/novelwriter/tools/mbuild.py b/novelwriter/tools/mbuild.py index f9a1de6b..74231c9f 100644 --- a/novelwriter/tools/mbuild.py +++ b/novelwriter/tools/mbuild.py @@ -170,10 +170,7 @@ class GuiBuildFilterTab(QWidget): C_STATUS = 2 D_HANDLE = Qt.UserRole - D_FILTER = Qt.UserRole + 1 - D_FILE = Qt.UserRole + 2 - D_NOVEL = Qt.UserRole + 3 - D_ACTIVE = Qt.UserRole + 4 + D_FILE = Qt.UserRole + 1 F_NONE = 0 F_FILTERED = 1 @@ -213,7 +210,6 @@ class GuiBuildFilterTab(QWidget): self.optTree.setHeaderHidden(True) self.optTree.setIndentation(iPx) self.optTree.setColumnCount(3) - self.optTree.setRootIsDecorated(False) treeHeader = self.optTree.header() treeHeader.setStretchLastSection(False) @@ -269,18 +265,19 @@ class GuiBuildFilterTab(QWidget): def populateTree(self): """Build the tree of project items. """ + logger.debug("Building project tree") self._treeMap = {} self.optTree.clear() + bSettings = self.buildOpts["settings"] for nwItem in self.theProject.getProjectItems(): tHandle = nwItem.itemHandle pHandle = nwItem.itemParent + rHandle = nwItem.itemRoot isFile = nwItem.isFileType() - isNovel = nwItem.isNovelLike() isActive = nwItem.isActive - if nwItem.isInactiveClass(): - logger.debug("Skipping inactive class item '%s'", tHandle) + if nwItem.isInactiveClass() or not bSettings.isRootAllowed(rHandle): continue hLevel = nwItem.mainHeading @@ -297,10 +294,7 @@ class GuiBuildFilterTab(QWidget): trItem.setIcon(self.C_NAME, itemIcon) trItem.setText(self.C_NAME, nwItem.itemName) trItem.setData(self.C_DATA, self.D_HANDLE, tHandle) - trItem.setData(self.C_DATA, self.D_FILTER, False) trItem.setData(self.C_DATA, self.D_FILE, isFile) - trItem.setData(self.C_DATA, self.D_NOVEL, isNovel) - trItem.setData(self.C_DATA, self.D_ACTIVE, isActive) trItem.setIcon(self.C_ACTIVE, self.mainTheme.getIcon(iconName)) trItem.setTextAlignment(self.C_NAME, Qt.AlignLeft) @@ -330,26 +324,26 @@ class GuiBuildFilterTab(QWidget): """Populate the filter options switches. """ self.filterOpt.clear() - buildSettings = self.buildOpts["settings"] + bSettings = self.buildOpts["settings"] - self.filterOpt.addLabel(buildSettings.getLabel("filter")) + self.filterOpt.addLabel(bSettings.getLabel("filter")) self.filterOpt.addItem( self.mainTheme.getIcon("proj_scene"), - buildSettings.getLabel("filter.includeNovel"), + bSettings.getLabel("filter.includeNovel"), "doc:filter.includeNovel", - default=buildSettings.getValue("filter.includeNovel") or False + default=bSettings.getValue("filter.includeNovel") or False ) self.filterOpt.addItem( self.mainTheme.getIcon("proj_note"), - buildSettings.getLabel("filter.includeNotes"), + bSettings.getLabel("filter.includeNotes"), "doc:filter.includeNotes", - default=buildSettings.getValue("filter.includeNotes") or False + default=bSettings.getValue("filter.includeNotes") or False ) self.filterOpt.addItem( self.mainTheme.getIcon("unchecked"), - buildSettings.getLabel("filter.includeInactive"), + bSettings.getLabel("filter.includeInactive"), "doc:filter.includeInactive", - default=buildSettings.getValue("filter.includeInactive") or False + default=bSettings.getValue("filter.includeInactive") or False ) self.filterOpt.addSeparator() @@ -376,6 +370,9 @@ class GuiBuildFilterTab(QWidget): if key.startswith("doc:"): self.buildOpts["settings"].setValue(key[4:], state) self._setTreeItemMode() + elif key.startswith("root:"): + self.buildOpts["settings"].setSkipRoot(key[5:], state) + self.populateTree() return ## @@ -385,7 +382,7 @@ class GuiBuildFilterTab(QWidget): def _setSelectedMode(self, mode): """Set the mode for the selected items. """ - buildSettings = self.buildOpts["settings"] + bSettings = self.buildOpts["settings"] for item in self.optTree.selectedItems(): if not isinstance(item, QTreeWidgetItem): @@ -395,11 +392,11 @@ class GuiBuildFilterTab(QWidget): isFile = item.data(self.C_DATA, self.D_FILE) if isFile: if mode == self.F_FILTERED: - buildSettings.setFiltered(tHandle) + bSettings.setFiltered(tHandle) elif mode == self.F_INCLUDED: - buildSettings.setIncluded(tHandle) + bSettings.setIncluded(tHandle) elif mode == self.F_EXCLUDED: - buildSettings.setExcluded(tHandle) + bSettings.setExcluded(tHandle) self._setTreeItemMode() @@ -419,7 +416,6 @@ class GuiBuildFilterTab(QWidget): item.setIcon(self.C_STATUS, self._statusFlags[self.F_FILTERED][1]) else: item.setIcon(self.C_STATUS, self._statusFlags[self.F_NONE][1]) - return # END Class GuiBuildFilterTab From 50f653fefb087011a0e1bab08e8820b21d45356a Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 13 May 2023 17:59:43 +0200 Subject: [PATCH 19/74] Add splitter to filter page and save to options --- novelwriter/core/options.py | 3 ++ novelwriter/tools/mbuild.py | 74 +++++++++++++++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/novelwriter/core/options.py b/novelwriter/core/options.py index 900701f6..f399bf4b 100644 --- a/novelwriter/core/options.py +++ b/novelwriter/core/options.py @@ -60,6 +60,9 @@ VALID_MAP = { }, "GuiWordList": {"winWidth", "winHeight"}, "GuiNovelView": {"lastCol", "lastColSize"}, + "GuiBuildManuscript": { + "winWidth", "winHeight", "treeWidth", "filterWidth" + }, } diff --git a/novelwriter/tools/mbuild.py b/novelwriter/tools/mbuild.py index 74231c9f..0ede1cdc 100644 --- a/novelwriter/tools/mbuild.py +++ b/novelwriter/tools/mbuild.py @@ -31,7 +31,8 @@ from PyQt5.QtGui import QIcon from PyQt5.QtCore import QSize, Qt, pyqtSlot from PyQt5.QtWidgets import ( QAbstractItemView, QDialog, QHBoxLayout, QHeaderView, QPushButton, - QStackedWidget, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget + QSplitter, QStackedWidget, QTreeWidget, QTreeWidgetItem, QVBoxLayout, + QWidget ) from novelwriter.core.buildsettings import BuildSettings, FilterMode @@ -67,10 +68,18 @@ class GuiBuildManuscript(QDialog): self.setWindowTitle(self.tr("Build Manuscript")) self.setMinimumWidth(self.mainConf.pxInt(700)) - self.setMinimumHeight(self.mainConf.pxInt(600)) + self.setMinimumHeight(self.mainConf.pxInt(400)) # Style mPx = self.mainConf.pxInt(150) + wWin = self.mainConf.pxInt(900) + hWin = self.mainConf.pxInt(600) + + pOptions = self.theProject.options + self.resize( + self.mainConf.pxInt(pOptions.getInt("GuiBuildManuscript", "winWidth", wWin)), + self.mainConf.pxInt(pOptions.getInt("GuiBuildManuscript", "winHeight", hWin)) + ) # Options SideBar # =============== @@ -159,6 +168,40 @@ class GuiBuildManuscript(QDialog): self.toolStack.setCurrentWidget(self.buildTabODT) return + ## + # Events + ## + + def closeEvent(self, event): + """Capture the user closing the window so we can save settings. + """ + self._saveSettings() + event.accept() + return + + ## + # Internal Functions + ## + + def _saveSettings(self): + """Save the various user settings. + """ + logger.debug("Saving GuiBuildManuscript settings") + + winWidth = self.mainConf.rpxInt(self.width()) + winHeight = self.mainConf.rpxInt(self.height()) + + treeWidth, filterWidth = self.optTabSelect.mainSplitSizes() + + pOptions = self.theProject.options + pOptions.setValue("GuiBuildManuscript", "winWidth", winWidth) + pOptions.setValue("GuiBuildManuscript", "winHeight", winHeight) + pOptions.setValue("GuiBuildManuscript", "treeWidth", treeWidth) + pOptions.setValue("GuiBuildManuscript", "filterWidth", filterWidth) + pOptions.saveSettings() + + return + # END Class GuiBuildManuscript @@ -250,18 +293,41 @@ class GuiBuildFilterTab(QWidget): self.filterOpt.switchToggled.connect(self._applyFilterSwitch) # Assemble + # ======== + + pOptions = self.theProject.options + wTree = self.mainConf.pxInt(pOptions.getInt("GuiBuildManuscript", "treeWidth", 0)) + fTree = self.mainConf.pxInt(pOptions.getInt("GuiBuildManuscript", "filterWidth", 0)) + self.selectionBox = QVBoxLayout() self.selectionBox.addLayout(self.modeBox) self.selectionBox.addWidget(self.filterOpt) + self.selectionBox.setContentsMargins(0, 0, 0, 0) + + self.selectionWidget = QWidget() + self.selectionWidget.setLayout(self.selectionBox) + + self.mainSplit = QSplitter() + self.mainSplit.addWidget(self.optTree) + self.mainSplit.addWidget(self.selectionWidget) + if wTree > 0: + self.mainSplit.setSizes([wTree, fTree]) self.outerBox = QHBoxLayout() - self.outerBox.addWidget(self.optTree) - self.outerBox.addLayout(self.selectionBox) + self.outerBox.addWidget(self.mainSplit) self.setLayout(self.outerBox) return + def mainSplitSizes(self): + """Extract the sizes of the main splitter. + """ + sizes = self.mainSplit.sizes() + if len(sizes) < 2: + return 0, 0 + return sizes[0], sizes[1] + def populateTree(self): """Build the tree of project items. """ From a4e5a8868663465547b14bee1ee82ac4c1826c45 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 13 May 2023 19:00:10 +0200 Subject: [PATCH 20/74] Add heading format fields --- novelwriter/core/buildsettings.py | 2 +- novelwriter/tools/mbuild.py | 123 +++++++++++++++++++++++++++++- 2 files changed, 120 insertions(+), 5 deletions(-) diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index 787101eb..161bbf6f 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -73,7 +73,7 @@ SETTINGS_LABELS = { "headings": QT_TRANSLATE_NOOP("Builds", "Headings"), "headings.fmtTitle": QT_TRANSLATE_NOOP("Builds", "Title Heading"), "headings.fmtChapter": QT_TRANSLATE_NOOP("Builds", "Chapter Heading"), - "headings.fmtUnnumbered": QT_TRANSLATE_NOOP("Builds", "Unnumbered Chapter"), + "headings.fmtUnnumbered": QT_TRANSLATE_NOOP("Builds", "Alt. Chapter Heading"), "headings.fmtScene": QT_TRANSLATE_NOOP("Builds", "Scene Heading"), "headings.fmtSection": QT_TRANSLATE_NOOP("Builds", "Section Heading"), "headings.hideScene": QT_TRANSLATE_NOOP("Builds", "Hide Scene"), diff --git a/novelwriter/tools/mbuild.py b/novelwriter/tools/mbuild.py index 0ede1cdc..e3fee97e 100644 --- a/novelwriter/tools/mbuild.py +++ b/novelwriter/tools/mbuild.py @@ -1,7 +1,7 @@ """ novelWriter – GUI Build Manuscript ================================== -GUI classes for the Manuscript build tool +GUI classes for the Manuscript Build Tool File History: Created: 2023-02-13 [2.1b1] @@ -30,12 +30,13 @@ import novelwriter from PyQt5.QtGui import QIcon from PyQt5.QtCore import QSize, Qt, pyqtSlot from PyQt5.QtWidgets import ( - QAbstractItemView, QDialog, QHBoxLayout, QHeaderView, QPushButton, - QSplitter, QStackedWidget, QTreeWidget, QTreeWidgetItem, QVBoxLayout, - QWidget + QAbstractItemView, QDialog, QGridLayout, QHBoxLayout, QHeaderView, QLabel, + QLineEdit, QPushButton, QSplitter, QStackedWidget, QToolButton, + QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget ) from novelwriter.core.buildsettings import BuildSettings, FilterMode +from novelwriter.extensions.switch import NSwitch from novelwriter.extensions.switchbox import NSwitchBox from novelwriter.extensions.pagedsidebar import NPagedSideBar @@ -492,6 +493,120 @@ class GuiBuildHeadingsTab(QWidget): def __init__(self, buildMain): super().__init__(parent=buildMain) + self.mainConf = novelwriter.CONFIG + self.mainGui = buildMain.mainGui + self.mainTheme = buildMain.mainGui.mainTheme + self.theProject = buildMain.mainGui.theProject + self.buildOpts = buildMain.buildOpts + + iPx = self.mainTheme.baseIconSize + vSp = self.mainConf.pxInt(12) + bSp = self.mainConf.pxInt(6) + bSettings = self.buildOpts["settings"] + + # Format Boxes + # ============ + self.formatBox = QGridLayout() + self.formatBox.setHorizontalSpacing(vSp) + + # Title Heading + self.lblTitle = QLabel(bSettings.getLabel("headings.fmtTitle")) + self.fmtTitle = QLineEdit("") + self.fmtTitle.setEnabled(False) + self.btnTitle = QToolButton() + self.btnTitle.setIcon(self.mainTheme.getIcon("edit")) + + wrapTitle = QHBoxLayout() + wrapTitle.addWidget(self.fmtTitle) + wrapTitle.addWidget(self.btnTitle) + wrapTitle.setSpacing(bSp) + + self.formatBox.addWidget(self.lblTitle, 0, 0, Qt.AlignLeft) + self.formatBox.addLayout(wrapTitle, 0, 1, Qt.AlignLeft) + + # Chapter Heading + self.lblChapter = QLabel(bSettings.getLabel("headings.fmtChapter")) + self.fmtChapter = QLineEdit("") + self.fmtChapter.setEnabled(False) + self.btnChapter = QToolButton() + self.btnChapter.setIcon(self.mainTheme.getIcon("edit")) + + wrapChapter = QHBoxLayout() + wrapChapter.addWidget(self.fmtChapter) + wrapChapter.addWidget(self.btnChapter) + wrapChapter.setSpacing(bSp) + + self.formatBox.addWidget(self.lblChapter, 1, 0, Qt.AlignLeft) + self.formatBox.addLayout(wrapChapter, 1, 1, Qt.AlignLeft) + + # Unnumbered Chapter Heading + self.lblUnChapter = QLabel(bSettings.getLabel("headings.fmtUnnumbered")) + self.fmtUnChapter = QLineEdit("") + self.fmtUnChapter.setEnabled(False) + self.btnUnChapter = QToolButton() + self.btnUnChapter.setIcon(self.mainTheme.getIcon("edit")) + + wrapUnChapter = QHBoxLayout() + wrapUnChapter.addWidget(self.fmtUnChapter) + wrapUnChapter.addWidget(self.btnUnChapter) + wrapUnChapter.setSpacing(bSp) + + self.formatBox.addWidget(self.lblUnChapter, 2, 0, Qt.AlignLeft) + self.formatBox.addLayout(wrapUnChapter, 2, 1, Qt.AlignLeft) + + # Scene Heading + self.lblScene = QLabel(bSettings.getLabel("headings.fmtScene")) + self.fmtScene = QLineEdit("") + self.fmtScene.setEnabled(False) + self.btnScene = QToolButton() + self.btnScene.setIcon(self.mainTheme.getIcon("edit")) + self.swtScene = NSwitch(width=2*iPx, height=iPx) + + wrapScene = QHBoxLayout() + wrapScene.addWidget(self.fmtScene) + wrapScene.addWidget(self.btnScene) + wrapScene.setSpacing(bSp) + + wrapSceneHide = QHBoxLayout() + wrapSceneHide.addWidget(QLabel(self.tr("Hide"))) + wrapSceneHide.addWidget(self.swtScene) + wrapSceneHide.setSpacing(bSp) + + self.formatBox.addWidget(self.lblScene, 3, 0, Qt.AlignLeft) + self.formatBox.addLayout(wrapScene, 3, 1, Qt.AlignLeft) + self.formatBox.addLayout(wrapSceneHide, 3, 2, Qt.AlignLeft) + + # Section Heading + self.lblSection = QLabel(bSettings.getLabel("headings.fmtSection")) + self.fmtSection = QLineEdit("") + self.fmtSection.setEnabled(False) + self.btnSection = QToolButton() + self.btnSection.setIcon(self.mainTheme.getIcon("edit")) + self.swtSection = NSwitch(width=2*iPx, height=iPx) + + wrapSection = QHBoxLayout() + wrapSection.addWidget(self.fmtSection) + wrapSection.addWidget(self.btnSection) + wrapSection.setSpacing(bSp) + + wrapSectionHide = QHBoxLayout() + wrapSectionHide.addWidget(QLabel(self.tr("Hide"))) + wrapSectionHide.addWidget(self.swtSection) + wrapSectionHide.setSpacing(bSp) + + self.formatBox.addWidget(self.lblSection, 4, 0, Qt.AlignLeft) + self.formatBox.addLayout(wrapSection, 4, 1, Qt.AlignLeft) + self.formatBox.addLayout(wrapSectionHide, 4, 2, Qt.AlignLeft) + + # Assemble + # ======== + + self.outerBox = QVBoxLayout() + self.outerBox.addLayout(self.formatBox) + self.outerBox.addStretch(1) + + self.setLayout(self.outerBox) + return # END Class GuiBuildHeadingsTab From ff0a4b03aea7e4e7cb0344f573a1517eac8b8533 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 13 May 2023 19:02:21 +0200 Subject: [PATCH 21/74] Rename GuiBuildManuscript to GuiBuildSettings --- novelwriter/core/options.py | 2 +- novelwriter/guimain.py | 8 +++---- .../tools/{mbuild.py => manussettings.py} | 22 +++++++++---------- 3 files changed, 16 insertions(+), 16 deletions(-) rename novelwriter/tools/{mbuild.py => manussettings.py} (96%) diff --git a/novelwriter/core/options.py b/novelwriter/core/options.py index f399bf4b..838372a8 100644 --- a/novelwriter/core/options.py +++ b/novelwriter/core/options.py @@ -60,7 +60,7 @@ VALID_MAP = { }, "GuiWordList": {"winWidth", "winHeight"}, "GuiNovelView": {"lastCol", "lastColSize"}, - "GuiBuildManuscript": { + "GuiBuildSettings": { "winWidth", "winHeight", "treeWidth", "filterWidth" }, } diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 97db3859..cb3faec6 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -57,9 +57,9 @@ from novelwriter.dialogs.projdetails import GuiProjectDetails from novelwriter.dialogs.projsettings import GuiProjectSettings from novelwriter.tools.build import GuiBuildNovel from novelwriter.tools.lipsum import GuiLipsum -from novelwriter.tools.mbuild import GuiBuildManuscript from novelwriter.tools.projwizard import GuiProjectWizard from novelwriter.tools.writingstats import GuiWritingStats +from novelwriter.tools.manussettings import GuiBuildSettings from novelwriter.core.project import NWProject from novelwriter.core.coretools import ProjectBuilder @@ -1021,10 +1021,10 @@ class GuiMain(QMainWindow): logger.error("No project open") return False - dlgBuild = getGuiItem("GuiBuildManuscript") + dlgBuild = getGuiItem("GuiBuildSettings") if dlgBuild is None: - dlgBuild = GuiBuildManuscript(self) - assert isinstance(dlgBuild, GuiBuildManuscript) + dlgBuild = GuiBuildSettings(self) + assert isinstance(dlgBuild, GuiBuildSettings) dlgBuild.setModal(False) dlgBuild.show() diff --git a/novelwriter/tools/mbuild.py b/novelwriter/tools/manussettings.py similarity index 96% rename from novelwriter/tools/mbuild.py rename to novelwriter/tools/manussettings.py index e3fee97e..e215f89d 100644 --- a/novelwriter/tools/mbuild.py +++ b/novelwriter/tools/manussettings.py @@ -43,7 +43,7 @@ from novelwriter.extensions.pagedsidebar import NPagedSideBar logger = logging.getLogger(__name__) -class GuiBuildManuscript(QDialog): +class GuiBuildSettings(QDialog): OPT_FILTERS = 1 OPT_HEADINGS = 2 @@ -78,8 +78,8 @@ class GuiBuildManuscript(QDialog): pOptions = self.theProject.options self.resize( - self.mainConf.pxInt(pOptions.getInt("GuiBuildManuscript", "winWidth", wWin)), - self.mainConf.pxInt(pOptions.getInt("GuiBuildManuscript", "winHeight", hWin)) + self.mainConf.pxInt(pOptions.getInt("GuiBuildSettings", "winWidth", wWin)), + self.mainConf.pxInt(pOptions.getInt("GuiBuildSettings", "winHeight", hWin)) ) # Options SideBar @@ -187,7 +187,7 @@ class GuiBuildManuscript(QDialog): def _saveSettings(self): """Save the various user settings. """ - logger.debug("Saving GuiBuildManuscript settings") + logger.debug("Saving GuiBuildSettings settings") winWidth = self.mainConf.rpxInt(self.width()) winHeight = self.mainConf.rpxInt(self.height()) @@ -195,15 +195,15 @@ class GuiBuildManuscript(QDialog): treeWidth, filterWidth = self.optTabSelect.mainSplitSizes() pOptions = self.theProject.options - pOptions.setValue("GuiBuildManuscript", "winWidth", winWidth) - pOptions.setValue("GuiBuildManuscript", "winHeight", winHeight) - pOptions.setValue("GuiBuildManuscript", "treeWidth", treeWidth) - pOptions.setValue("GuiBuildManuscript", "filterWidth", filterWidth) + pOptions.setValue("GuiBuildSettings", "winWidth", winWidth) + pOptions.setValue("GuiBuildSettings", "winHeight", winHeight) + pOptions.setValue("GuiBuildSettings", "treeWidth", treeWidth) + pOptions.setValue("GuiBuildSettings", "filterWidth", filterWidth) pOptions.saveSettings() return -# END Class GuiBuildManuscript +# END Class GuiBuildSettings class GuiBuildFilterTab(QWidget): @@ -297,8 +297,8 @@ class GuiBuildFilterTab(QWidget): # ======== pOptions = self.theProject.options - wTree = self.mainConf.pxInt(pOptions.getInt("GuiBuildManuscript", "treeWidth", 0)) - fTree = self.mainConf.pxInt(pOptions.getInt("GuiBuildManuscript", "filterWidth", 0)) + wTree = self.mainConf.pxInt(pOptions.getInt("GuiBuildSettings", "treeWidth", 0)) + fTree = self.mainConf.pxInt(pOptions.getInt("GuiBuildSettings", "filterWidth", 0)) self.selectionBox = QVBoxLayout() self.selectionBox.addLayout(self.modeBox) From 26430e642c1a2625697118d652deb138af4bafbd Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 13 May 2023 19:20:31 +0200 Subject: [PATCH 22/74] Add a new Manuscript Build tool --- novelwriter/core/options.py | 3 + novelwriter/guimain.py | 8 +- novelwriter/tools/manusbuild.py | 141 +++++++++++++++++++++++++++++ novelwriter/tools/manussettings.py | 12 +-- 4 files changed, 153 insertions(+), 11 deletions(-) create mode 100644 novelwriter/tools/manusbuild.py diff --git a/novelwriter/core/options.py b/novelwriter/core/options.py index 838372a8..8637d937 100644 --- a/novelwriter/core/options.py +++ b/novelwriter/core/options.py @@ -63,6 +63,9 @@ VALID_MAP = { "GuiBuildSettings": { "winWidth", "winHeight", "treeWidth", "filterWidth" }, + "GuiBuildManuscript": { + "winWidth", "winHeight", "optsWidth", "viewWidth" + }, } diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index cb3faec6..700b7ba7 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -57,9 +57,9 @@ from novelwriter.dialogs.projdetails import GuiProjectDetails from novelwriter.dialogs.projsettings import GuiProjectSettings from novelwriter.tools.build import GuiBuildNovel from novelwriter.tools.lipsum import GuiLipsum +from novelwriter.tools.manusbuild import GuiBuildManuscript from novelwriter.tools.projwizard import GuiProjectWizard from novelwriter.tools.writingstats import GuiWritingStats -from novelwriter.tools.manussettings import GuiBuildSettings from novelwriter.core.project import NWProject from novelwriter.core.coretools import ProjectBuilder @@ -1021,10 +1021,10 @@ class GuiMain(QMainWindow): logger.error("No project open") return False - dlgBuild = getGuiItem("GuiBuildSettings") + dlgBuild = getGuiItem("GuiBuildManuscript") if dlgBuild is None: - dlgBuild = GuiBuildSettings(self) - assert isinstance(dlgBuild, GuiBuildSettings) + dlgBuild = GuiBuildManuscript(self) + assert isinstance(dlgBuild, GuiBuildManuscript) dlgBuild.setModal(False) dlgBuild.show() diff --git a/novelwriter/tools/manusbuild.py b/novelwriter/tools/manusbuild.py new file mode 100644 index 00000000..dc871a2d --- /dev/null +++ b/novelwriter/tools/manusbuild.py @@ -0,0 +1,141 @@ +""" +novelWriter – GUI Build Manuscript +================================== +GUI classes for the Manuscript Build Tool + +File History: +Created: 2023-05-13 [2.1b1] + +This file is a part of novelWriter +Copyright 2018–2023, 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 . +""" + +import logging +import novelwriter + +from PyQt5.QtWidgets import QDialog, QGridLayout, QSplitter, QTextBrowser, QVBoxLayout, QWidget + +logger = logging.getLogger(__name__) + + +class GuiBuildManuscript(QDialog): + + def __init__(self, mainGui): + super().__init__(parent=mainGui) + + self.mainConf = novelwriter.CONFIG + self.mainGui = mainGui + self.mainTheme = mainGui.mainTheme + self.theProject = mainGui.theProject + + self.setWindowTitle(self.tr("Build Manuscript")) + self.setMinimumWidth(self.mainConf.pxInt(600)) + self.setMinimumHeight(self.mainConf.pxInt(500)) + + wWin = self.mainConf.pxInt(900) + hWin = self.mainConf.pxInt(600) + + pOptions = self.theProject.options + self.resize( + self.mainConf.pxInt(pOptions.getInt("GuiBuildManuscript", "winWidth", wWin)), + self.mainConf.pxInt(pOptions.getInt("GuiBuildManuscript", "winHeight", hWin)) + ) + + # Controls + # ======== + + self.optsGrid = QGridLayout() + + self.manPreview = GuiManuscriptPreview(self) + + # Assemble GUI + # ============ + + self.optsWidget = QWidget() + self.optsWidget.setLayout(self.optsGrid) + + self.mainSplit = QSplitter() + self.mainSplit.addWidget(self.optsWidget) + self.mainSplit.addWidget(self.manPreview) + self.mainSplit.setSizes([ + self.mainConf.pxInt(pOptions.getInt("GuiBuildManuscript", "optsWidth", wWin//3)), + self.mainConf.pxInt(pOptions.getInt("GuiBuildManuscript", "viewWidth", 2*wWin//3)), + ]) + + self.outerBox = QVBoxLayout() + self.outerBox.addWidget(self.mainSplit) + + self.setLayout(self.outerBox) + + return + + def loadContent(self): + """ + """ + return + + ## + # Events + ## + + def closeEvent(self, event): + """Capture the user closing the window so we can save settings. + """ + self._saveSettings() + event.accept() + return + + ## + # Internal Functions + ## + + def _saveSettings(self): + """Save the various user settings. + """ + logger.debug("Saving GuiBuildManuscript settings") + + winWidth = self.mainConf.rpxInt(self.width()) + winHeight = self.mainConf.rpxInt(self.height()) + + mainSplit = self.mainSplit.sizes() + optsWidth = mainSplit[0] + viewWidth = mainSplit[1] + + pOptions = self.theProject.options + pOptions.setValue("GuiBuildManuscript", "winWidth", winWidth) + pOptions.setValue("GuiBuildManuscript", "winHeight", winHeight) + pOptions.setValue("GuiBuildManuscript", "optsWidth", optsWidth) + pOptions.setValue("GuiBuildManuscript", "viewWidth", viewWidth) + pOptions.saveSettings() + + return + +# END Class GuiBuildManuscript + + +class GuiManuscriptPreview(QTextBrowser): + + def __init__(self, mainGui): + super().__init__(parent=mainGui) + + self.mainConf = novelwriter.CONFIG + self.mainGui = mainGui + self.mainTheme = mainGui.mainTheme + self.theProject = mainGui.theProject + + return + +# END Class GuiManuscriptPreview diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index e215f89d..65d517ce 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -1,7 +1,7 @@ """ -novelWriter – GUI Build Manuscript -================================== -GUI classes for the Manuscript Build Tool +novelWriter – GUI Build Settings +================================ +GUI classes for editing Manuscript Build Settings File History: Created: 2023-02-13 [2.1b1] @@ -24,7 +24,6 @@ along with this program. If not, see . """ import logging - import novelwriter from PyQt5.QtGui import QIcon @@ -67,18 +66,17 @@ class GuiBuildSettings(QDialog): "filter": {}, } - self.setWindowTitle(self.tr("Build Manuscript")) + self.setWindowTitle(self.tr("Manuscript Build Settings")) self.setMinimumWidth(self.mainConf.pxInt(700)) self.setMinimumHeight(self.mainConf.pxInt(400)) - # Style mPx = self.mainConf.pxInt(150) wWin = self.mainConf.pxInt(900) hWin = self.mainConf.pxInt(600) pOptions = self.theProject.options self.resize( - self.mainConf.pxInt(pOptions.getInt("GuiBuildSettings", "winWidth", wWin)), + self.mainConf.pxInt(pOptions.getInt("GuiBuildSettings", "winWidth", wWin)), self.mainConf.pxInt(pOptions.getInt("GuiBuildSettings", "winHeight", hWin)) ) From 5685963b59928328c35258243a7d6f83e6b83463 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 13 May 2023 20:24:33 +0200 Subject: [PATCH 23/74] Modify logic so build settings are edited separately --- novelwriter/core/buildsettings.py | 143 ++++++++++++++++++++++++----- novelwriter/tools/manusbuild.py | 33 ++++++- novelwriter/tools/manussettings.py | 125 ++++++++++++++----------- 3 files changed, 225 insertions(+), 76 deletions(-) diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index 161bbf6f..a6880525 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -23,12 +23,14 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ +import uuid import logging from enum import Enum from PyQt5.QtCore import QT_TRANSLATE_NOOP +from novelwriter.common import checkUuid, isHandle from novelwriter.core.item import NWItem from novelwriter.core.project import NWProject @@ -115,29 +117,72 @@ class FilterMode(Enum): class BuildSettings: def __init__(self): - self._skiproot = set() + self._name = "" + self._uuid = "" + self._skipRoot = set() self._excluded = set() self._included = set() self._settings = {k: v[1] for k, v in SETTINGS_TEMPLATE.items()} + self._changed = False return - def isFiltered(self, tHandle): - return tHandle not in self._included and tHandle not in self._excluded + ## + # Properties + ## - def isIncluded(self, tHandle): - return tHandle in self._included + @property + def name(self): + return self._name - def isExcluded(self, tHandle): - return tHandle in self._excluded + @property + def buildID(self): + return self._uuid - def isRootAllowed(self, tHandle): - return tHandle not in self._skiproot + @property + def changed(self): + return self._changed + + ## + # Getters + ## + + @staticmethod + def getLabel(key): + """Extract the label for a specific item. + """ + return SETTINGS_LABELS.get(key, "ERROR") + + def getValue(self, key): + """Get the value for a specific item, or return the default. + """ + return self._settings.get(key, SETTINGS_TEMPLATE.get(key, (None, None)[1])) + + ## + # Setters + ## + + def setName(self, name): + """Set the build setting display name. + """ + self._name = str(name) + return + + def setBuildID(self, value): + """Set a UUID build ID. + """ + value = checkUuid(value, "") + if not value: + self._uuid = str(uuid.uuid4()) + elif value != self._uuid: + self._uuid = value + return def setFiltered(self, tHandle): """Set an item as filtered. """ self._excluded.discard(tHandle) self._included.discard(tHandle) + self._changed = True return def setIncluded(self, tHandle): @@ -145,6 +190,7 @@ class BuildSettings: """ self._excluded.discard(tHandle) self._included.add(tHandle) + self._changed = True return def setExcluded(self, tHandle): @@ -152,15 +198,18 @@ class BuildSettings: """ self._excluded.add(tHandle) self._included.discard(tHandle) + self._changed = True return def setSkipRoot(self, tHandle, state): """Set a specific root folder as skipped or not. """ if state is True: - self._skiproot.discard(tHandle) + self._skipRoot.discard(tHandle) + self._changed = True elif state is False: - self._skiproot.add(tHandle) + self._skipRoot.add(tHandle) + self._changed = True return def setValue(self, key, value): @@ -174,20 +223,27 @@ class BuildSettings: if len(definition) == 4: value = min(max(value, definition[2]), definition[3]) self._settings[key] = value + self._changed = True logger.debug(f"Build Setting '{key}' set to: {value}") return True - def getLabel(self, key): - """Extract the label for a specific item. - """ - return SETTINGS_LABELS.get(key, "ERROR") + ## + # Methods + ## - def getValue(self, key): - """Get the value for a specific item, or return the default. - """ - return self._settings.get(key, SETTINGS_TEMPLATE.get(key, (None, None)[1])) + def isFiltered(self, tHandle): + return tHandle not in self._included and tHandle not in self._excluded - def checkItemFilter(self, project): + def isIncluded(self, tHandle): + return tHandle in self._included + + def isExcluded(self, tHandle): + return tHandle in self._excluded + + def isRootAllowed(self, tHandle): + return tHandle not in self._skipRoot + + def buildItemFilter(self, project): """Return a dictionary of item handles with filter decissions applied. """ @@ -204,7 +260,7 @@ class BuildSettings: if not isinstance(item, NWItem): result[tHandle] = (False, FilterMode.UNKNOWN) continue - if item.isInactiveClass() or (item.itemRoot in self._skiproot): + if item.isInactiveClass() or (item.itemRoot in self._skipRoot): result[tHandle] = (False, FilterMode.SKIPPED) continue if not item.isFileType(): @@ -230,4 +286,49 @@ class BuildSettings: return result + def resetChangedState(self): + """This must be called when the changes to this class has been + safely saved to file or passed on. + """ + self._changed = False + return + + def pack(self): + """Pack all content into a JSON compatible dictionary. + """ + return { + "name": self._name, + "uuid": self._uuid, + "settings": self._settings.copy(), + "included": list(self._included), + "excluded": list(self._excluded), + "skipRoot": list(self._skipRoot), + } + + def unpack(self, data): + """Unpack a dictionary and populate the class. + """ + included = data.get("included", []) + excluded = data.get("excluded", []) + skipRoot = data.get("skipRoot", []) + settings = data.get("settings", {}) + + self.setName(data.get("name", "")) + self.setBuildID(data.get("uuid", "")) + if isinstance(included, list): + self._included = set([h for h in included if isHandle(h)]) + if isinstance(excluded, list): + self._excluded = set([h for h in excluded if isHandle(h)]) + if isinstance(skipRoot, list): + self._skipRoot = set([h for h in skipRoot if isHandle(h)]) + + self._settings = {k: v[1] for k, v in SETTINGS_TEMPLATE.items()} + if isinstance(settings, dict): + for key, value in settings.items(): + self.setValue(key, value) + + self._changed = False + + return + # END Class BuildSettings diff --git a/novelwriter/tools/manusbuild.py b/novelwriter/tools/manusbuild.py index dc871a2d..b55ba46b 100644 --- a/novelwriter/tools/manusbuild.py +++ b/novelwriter/tools/manusbuild.py @@ -26,7 +26,13 @@ along with this program. If not, see . import logging import novelwriter -from PyQt5.QtWidgets import QDialog, QGridLayout, QSplitter, QTextBrowser, QVBoxLayout, QWidget +from PyQt5.QtWidgets import ( + QDialog, QGridLayout, QPushButton, QSplitter, QTextBrowser, QVBoxLayout, + QWidget, qApp +) + +from novelwriter.common import getGuiItem +from novelwriter.tools.manussettings import GuiBuildSettings logger = logging.getLogger(__name__) @@ -57,7 +63,11 @@ class GuiBuildManuscript(QDialog): # Controls # ======== + self.btnNew = QPushButton(self.tr("New Build")) + self.btnNew.clicked.connect(self._createNewBuild) + self.optsGrid = QGridLayout() + self.optsGrid.addWidget(self.btnNew, 0, 0) self.manPreview = GuiManuscriptPreview(self) @@ -98,6 +108,27 @@ class GuiBuildManuscript(QDialog): event.accept() return + ## + # Private Slots + ## + + def _createNewBuild(self): + """Open the build settings dialog for a new build. + """ + dlgSettings = getGuiItem("GuiBuildSettings") + if dlgSettings is None: + dlgSettings = GuiBuildSettings(self) + assert isinstance(dlgSettings, GuiBuildSettings) + + dlgSettings.setModal(False) + dlgSettings.show() + dlgSettings.raise_() + qApp.processEvents() + + dlgSettings.loadContent({"name": self.tr("My Manuscript")}) + + return + ## # Internal Functions ## diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index 65d517ce..3207d2fc 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -60,11 +60,7 @@ class GuiBuildSettings(QDialog): self.mainTheme = mainGui.mainTheme self.theProject = mainGui.theProject - self.buildOpts = { - "name": self.tr("Default Settings"), - "settings": BuildSettings(), - "filter": {}, - } + self._build = None self.setWindowTitle(self.tr("Manuscript Build Settings")) self.setMinimumWidth(self.mainConf.pxInt(700)) @@ -136,11 +132,17 @@ class GuiBuildSettings(QDialog): return - def loadContent(self): - """Populate the tool widgets. + def loadContent(self, data): + """Populate the child widgets. """ - self.optTabSelect.populateTree() - self.optTabSelect.populateFilters() + if isinstance(self._build, BuildSettings) and self._build.changed: + logger.error("There are unsaved changes in this GUI") + return + + self._build = BuildSettings() + self._build.unpack(data) + self.optTabSelect.loadContent(self._build) + return ## @@ -226,9 +228,9 @@ class GuiBuildFilterTab(QWidget): self.mainGui = buildMain.mainGui self.mainTheme = buildMain.mainGui.mainTheme self.theProject = buildMain.mainGui.theProject - self.buildOpts = buildMain.buildOpts self._treeMap = {} + self._build = None self._statusFlags = { self.F_NONE: ("", QIcon()), @@ -319,6 +321,14 @@ class GuiBuildFilterTab(QWidget): return + def loadContent(self, build): + """Populate the widgets. + """ + self._build = build + self._populateTree() + self._populateFilters() + return + def mainSplitSizes(self): """Extract the sizes of the main splitter. """ @@ -327,13 +337,37 @@ class GuiBuildFilterTab(QWidget): return 0, 0 return sizes[0], sizes[1] - def populateTree(self): + ## + # Slots + ## + + @pyqtSlot(str, bool) + def _applyFilterSwitch(self, key, state): + """A filter switch has been toggled, so update the settings. + """ + if not isinstance(self._build, BuildSettings): + return + if key.startswith("doc:"): + self._build.setValue(key[4:], state) + self._setTreeItemMode() + elif key.startswith("root:"): + self._build.setSkipRoot(key[5:], state) + self._populateTree() + return + + ## + # Internal Functions + ## + + def _populateTree(self): """Build the tree of project items. """ + if not isinstance(self._build, BuildSettings): + return + logger.debug("Building project tree") self._treeMap = {} self.optTree.clear() - bSettings = self.buildOpts["settings"] for nwItem in self.theProject.getProjectItems(): tHandle = nwItem.itemHandle @@ -342,7 +376,7 @@ class GuiBuildFilterTab(QWidget): isFile = nwItem.isFileType() isActive = nwItem.isActive - if nwItem.isInactiveClass() or not bSettings.isRootAllowed(rHandle): + if nwItem.isInactiveClass() or not self._build.isRootAllowed(rHandle): continue hLevel = nwItem.mainHeading @@ -385,30 +419,31 @@ class GuiBuildFilterTab(QWidget): return - def populateFilters(self): + def _populateFilters(self): """Populate the filter options switches. """ self.filterOpt.clear() - bSettings = self.buildOpts["settings"] + if not isinstance(self._build, BuildSettings): + return - self.filterOpt.addLabel(bSettings.getLabel("filter")) + self.filterOpt.addLabel(self._build.getLabel("filter")) self.filterOpt.addItem( self.mainTheme.getIcon("proj_scene"), - bSettings.getLabel("filter.includeNovel"), + self._build.getLabel("filter.includeNovel"), "doc:filter.includeNovel", - default=bSettings.getValue("filter.includeNovel") or False + default=self._build.getValue("filter.includeNovel") or False ) self.filterOpt.addItem( self.mainTheme.getIcon("proj_note"), - bSettings.getLabel("filter.includeNotes"), + self._build.getLabel("filter.includeNotes"), "doc:filter.includeNotes", - default=bSettings.getValue("filter.includeNotes") or False + default=self._build.getValue("filter.includeNotes") or False ) self.filterOpt.addItem( self.mainTheme.getIcon("unchecked"), - bSettings.getLabel("filter.includeInactive"), + self._build.getLabel("filter.includeInactive"), "doc:filter.includeInactive", - default=bSettings.getValue("filter.includeInactive") or False + default=self._build.getValue("filter.includeInactive") or False ) self.filterOpt.addSeparator() @@ -424,30 +459,11 @@ class GuiBuildFilterTab(QWidget): return - ## - # Slots - ## - - @pyqtSlot(str, bool) - def _applyFilterSwitch(self, key, state): - """A filter switch has been toggled, so update the settings. - """ - if key.startswith("doc:"): - self.buildOpts["settings"].setValue(key[4:], state) - self._setTreeItemMode() - elif key.startswith("root:"): - self.buildOpts["settings"].setSkipRoot(key[5:], state) - self.populateTree() - return - - ## - # Internal Functions - ## - def _setSelectedMode(self, mode): """Set the mode for the selected items. """ - bSettings = self.buildOpts["settings"] + if not isinstance(self._build, BuildSettings): + return for item in self.optTree.selectedItems(): if not isinstance(item, QTreeWidgetItem): @@ -457,11 +473,11 @@ class GuiBuildFilterTab(QWidget): isFile = item.data(self.C_DATA, self.D_FILE) if isFile: if mode == self.F_FILTERED: - bSettings.setFiltered(tHandle) + self._build.setFiltered(tHandle) elif mode == self.F_INCLUDED: - bSettings.setIncluded(tHandle) + self._build.setIncluded(tHandle) elif mode == self.F_EXCLUDED: - bSettings.setExcluded(tHandle) + self._build.setExcluded(tHandle) self._setTreeItemMode() @@ -470,7 +486,10 @@ class GuiBuildFilterTab(QWidget): def _setTreeItemMode(self): """Update the filtered mode icon on all items. """ - filtered = self.buildOpts["settings"].checkItemFilter(self.theProject) + if not isinstance(self._build, BuildSettings): + return + + filtered = self._build.buildItemFilter(self.theProject) for tHandle, item in self._treeMap.items(): allow, mode = filtered.get(tHandle, (False, FilterMode.UNKNOWN)) if mode == FilterMode.INCLUDED: @@ -495,12 +514,10 @@ class GuiBuildHeadingsTab(QWidget): self.mainGui = buildMain.mainGui self.mainTheme = buildMain.mainGui.mainTheme self.theProject = buildMain.mainGui.theProject - self.buildOpts = buildMain.buildOpts iPx = self.mainTheme.baseIconSize vSp = self.mainConf.pxInt(12) bSp = self.mainConf.pxInt(6) - bSettings = self.buildOpts["settings"] # Format Boxes # ============ @@ -508,7 +525,7 @@ class GuiBuildHeadingsTab(QWidget): self.formatBox.setHorizontalSpacing(vSp) # Title Heading - self.lblTitle = QLabel(bSettings.getLabel("headings.fmtTitle")) + self.lblTitle = QLabel(BuildSettings.getLabel("headings.fmtTitle")) self.fmtTitle = QLineEdit("") self.fmtTitle.setEnabled(False) self.btnTitle = QToolButton() @@ -523,7 +540,7 @@ class GuiBuildHeadingsTab(QWidget): self.formatBox.addLayout(wrapTitle, 0, 1, Qt.AlignLeft) # Chapter Heading - self.lblChapter = QLabel(bSettings.getLabel("headings.fmtChapter")) + self.lblChapter = QLabel(BuildSettings.getLabel("headings.fmtChapter")) self.fmtChapter = QLineEdit("") self.fmtChapter.setEnabled(False) self.btnChapter = QToolButton() @@ -538,7 +555,7 @@ class GuiBuildHeadingsTab(QWidget): self.formatBox.addLayout(wrapChapter, 1, 1, Qt.AlignLeft) # Unnumbered Chapter Heading - self.lblUnChapter = QLabel(bSettings.getLabel("headings.fmtUnnumbered")) + self.lblUnChapter = QLabel(BuildSettings.getLabel("headings.fmtUnnumbered")) self.fmtUnChapter = QLineEdit("") self.fmtUnChapter.setEnabled(False) self.btnUnChapter = QToolButton() @@ -553,7 +570,7 @@ class GuiBuildHeadingsTab(QWidget): self.formatBox.addLayout(wrapUnChapter, 2, 1, Qt.AlignLeft) # Scene Heading - self.lblScene = QLabel(bSettings.getLabel("headings.fmtScene")) + self.lblScene = QLabel(BuildSettings.getLabel("headings.fmtScene")) self.fmtScene = QLineEdit("") self.fmtScene.setEnabled(False) self.btnScene = QToolButton() @@ -575,7 +592,7 @@ class GuiBuildHeadingsTab(QWidget): self.formatBox.addLayout(wrapSceneHide, 3, 2, Qt.AlignLeft) # Section Heading - self.lblSection = QLabel(bSettings.getLabel("headings.fmtSection")) + self.lblSection = QLabel(BuildSettings.getLabel("headings.fmtSection")) self.fmtSection = QLineEdit("") self.fmtSection.setEnabled(False) self.btnSection = QToolButton() From 35731ca4f7034a89fd342255beee3be16a89a5fd Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Mon, 15 May 2023 15:27:02 +0200 Subject: [PATCH 24/74] Make build settings dialog work with single settings object --- novelwriter/tools/manusbuild.py | 10 +-- novelwriter/tools/manussettings.py | 113 ++++++++++++++++------------- 2 files changed, 66 insertions(+), 57 deletions(-) diff --git a/novelwriter/tools/manusbuild.py b/novelwriter/tools/manusbuild.py index b55ba46b..f3a60162 100644 --- a/novelwriter/tools/manusbuild.py +++ b/novelwriter/tools/manusbuild.py @@ -31,7 +31,6 @@ from PyQt5.QtWidgets import ( QWidget, qApp ) -from novelwriter.common import getGuiItem from novelwriter.tools.manussettings import GuiBuildSettings logger = logging.getLogger(__name__) @@ -115,17 +114,14 @@ class GuiBuildManuscript(QDialog): def _createNewBuild(self): """Open the build settings dialog for a new build. """ - dlgSettings = getGuiItem("GuiBuildSettings") - if dlgSettings is None: - dlgSettings = GuiBuildSettings(self) - assert isinstance(dlgSettings, GuiBuildSettings) + data = {"name": self.tr("My Manuscript")} + dlgSettings = GuiBuildSettings(self.mainGui, data) dlgSettings.setModal(False) dlgSettings.show() dlgSettings.raise_() qApp.processEvents() - - dlgSettings.loadContent({"name": self.tr("My Manuscript")}) + dlgSettings.loadContent() return diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index 3207d2fc..2ef9c3fc 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -22,12 +22,15 @@ 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 . """ +from __future__ import annotations import logging import novelwriter +from typing import TYPE_CHECKING + from PyQt5.QtGui import QIcon -from PyQt5.QtCore import QSize, Qt, pyqtSlot +from PyQt5.QtCore import QEvent, QSize, Qt, pyqtSlot from PyQt5.QtWidgets import ( QAbstractItemView, QDialog, QGridLayout, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QPushButton, QSplitter, QStackedWidget, QToolButton, @@ -39,6 +42,9 @@ from novelwriter.extensions.switch import NSwitch from novelwriter.extensions.switchbox import NSwitchBox from novelwriter.extensions.pagedsidebar import NPagedSideBar +if TYPE_CHECKING: + from novelwriter.guimain import GuiMain + logger = logging.getLogger(__name__) @@ -52,15 +58,19 @@ class GuiBuildSettings(QDialog): BLD_MARKDOWN = 6 BLD_ODT = 7 - def __init__(self, mainGui): + def __init__(self, mainGui: GuiMain, buildData: dict): super().__init__(parent=mainGui) + logger.debug("Initialising GuiBuildSettings ...") + self.setObjectName("GuiBuildSettings") + self.mainConf = novelwriter.CONFIG self.mainGui = mainGui self.mainTheme = mainGui.mainTheme self.theProject = mainGui.theProject - self._build = None + self._build = BuildSettings() + self._build.unpack(buildData) self.setWindowTitle(self.tr("Manuscript Build Settings")) self.setMinimumWidth(self.mainConf.pxInt(700)) @@ -102,13 +112,13 @@ class GuiBuildSettings(QDialog): # ============ # Create Tabs - self.optTabSelect = GuiBuildFilterTab(self) - self.optTabHeadings = GuiBuildHeadingsTab(self) - self.optTabFormat = GuiBuildFormatTab(self) - self.optTabContent = GuiBuildContentTab(self) - self.buildTabHTML = GuiBuildHTMLTab(self) - self.buildTabMarkdown = GuiBuildMarkdownTab(self) - self.buildTabODT = GuiBuildODTTab(self) + self.optTabSelect = GuiBuildFilterTab(self, self._build) + self.optTabHeadings = GuiBuildHeadingsTab(self, self._build) + self.optTabFormat = GuiBuildFormatTab(self, self._build) + self.optTabContent = GuiBuildContentTab(self, self._build) + self.buildTabHTML = GuiBuildHTMLTab(self, self._build) + self.buildTabMarkdown = GuiBuildMarkdownTab(self, self._build) + self.buildTabODT = GuiBuildODTTab(self, self._build) # Add Tabs self.toolStack = QStackedWidget(self) @@ -120,29 +130,34 @@ class GuiBuildSettings(QDialog): self.toolStack.addWidget(self.buildTabMarkdown) self.toolStack.addWidget(self.buildTabODT) - # Assemble - self.outerBox = QHBoxLayout() - self.outerBox.addWidget(self.optSideBar) - self.outerBox.addWidget(self.toolStack) + # Main Settings + # ============= + + self.lblTitle = QLabel() + + # Assemble GUI + # ============ + + self.mainBox = QHBoxLayout() + self.mainBox.addWidget(self.optSideBar) + self.mainBox.addWidget(self.toolStack) + + self.outerBox = QVBoxLayout() + self.outerBox.addLayout(self.mainBox) self.setLayout(self.outerBox) # Set Default Tab self.optSideBar.setSelected(self.OPT_FILTERS) + logger.debug("GuiBuildSettings initialisation complete") + return - def loadContent(self, data): + def loadContent(self): """Populate the child widgets. """ - if isinstance(self._build, BuildSettings) and self._build.changed: - logger.error("There are unsaved changes in this GUI") - return - - self._build = BuildSettings() - self._build.unpack(data) self.optTabSelect.loadContent(self._build) - return ## @@ -150,7 +165,7 @@ class GuiBuildSettings(QDialog): ## @pyqtSlot(int) - def _stackPageSelected(self, pageId): + def _stackPageSelected(self, pageId: int): """Process a user request to switch page. """ if pageId == self.OPT_FILTERS: @@ -173,7 +188,7 @@ class GuiBuildSettings(QDialog): # Events ## - def closeEvent(self, event): + def closeEvent(self, event: QEvent): """Capture the user closing the window so we can save settings. """ self._saveSettings() @@ -221,7 +236,7 @@ class GuiBuildFilterTab(QWidget): F_INCLUDED = 2 F_EXCLUDED = 3 - def __init__(self, buildMain): + def __init__(self, buildMain: GuiBuildSettings, build: BuildSettings): super().__init__(parent=buildMain) self.mainConf = novelwriter.CONFIG @@ -230,7 +245,7 @@ class GuiBuildFilterTab(QWidget): self.theProject = buildMain.mainGui.theProject self._treeMap = {} - self._build = None + self._build = build self._statusFlags = { self.F_NONE: ("", QIcon()), @@ -321,7 +336,7 @@ class GuiBuildFilterTab(QWidget): return - def loadContent(self, build): + def loadContent(self, build: BuildSettings): """Populate the widgets. """ self._build = build @@ -329,7 +344,7 @@ class GuiBuildFilterTab(QWidget): self._populateFilters() return - def mainSplitSizes(self): + def mainSplitSizes(self) -> tuple[int, int]: """Extract the sizes of the main splitter. """ sizes = self.mainSplit.sizes() @@ -342,11 +357,9 @@ class GuiBuildFilterTab(QWidget): ## @pyqtSlot(str, bool) - def _applyFilterSwitch(self, key, state): + def _applyFilterSwitch(self, key: str, state: bool): """A filter switch has been toggled, so update the settings. """ - if not isinstance(self._build, BuildSettings): - return if key.startswith("doc:"): self._build.setValue(key[4:], state) self._setTreeItemMode() @@ -362,9 +375,6 @@ class GuiBuildFilterTab(QWidget): def _populateTree(self): """Build the tree of project items. """ - if not isinstance(self._build, BuildSettings): - return - logger.debug("Building project tree") self._treeMap = {} self.optTree.clear() @@ -423,9 +433,6 @@ class GuiBuildFilterTab(QWidget): """Populate the filter options switches. """ self.filterOpt.clear() - if not isinstance(self._build, BuildSettings): - return - self.filterOpt.addLabel(self._build.getLabel("filter")) self.filterOpt.addItem( self.mainTheme.getIcon("proj_scene"), @@ -459,12 +466,9 @@ class GuiBuildFilterTab(QWidget): return - def _setSelectedMode(self, mode): + def _setSelectedMode(self, mode: int): """Set the mode for the selected items. """ - if not isinstance(self._build, BuildSettings): - return - for item in self.optTree.selectedItems(): if not isinstance(item, QTreeWidgetItem): continue @@ -486,9 +490,6 @@ class GuiBuildFilterTab(QWidget): def _setTreeItemMode(self): """Update the filtered mode icon on all items. """ - if not isinstance(self._build, BuildSettings): - return - filtered = self._build.buildItemFilter(self.theProject) for tHandle, item in self._treeMap.items(): allow, mode = filtered.get(tHandle, (False, FilterMode.UNKNOWN)) @@ -507,7 +508,7 @@ class GuiBuildFilterTab(QWidget): class GuiBuildHeadingsTab(QWidget): - def __init__(self, buildMain): + def __init__(self, buildMain: GuiBuildSettings, build: BuildSettings): super().__init__(parent=buildMain) self.mainConf = novelwriter.CONFIG @@ -515,6 +516,8 @@ class GuiBuildHeadingsTab(QWidget): self.mainTheme = buildMain.mainGui.mainTheme self.theProject = buildMain.mainGui.theProject + self._build = build + iPx = self.mainTheme.baseIconSize vSp = self.mainConf.pxInt(12) bSp = self.mainConf.pxInt(6) @@ -629,9 +632,11 @@ class GuiBuildHeadingsTab(QWidget): class GuiBuildFormatTab(QWidget): - def __init__(self, buildMain): + def __init__(self, buildMain: GuiBuildSettings, build: BuildSettings): super().__init__(parent=buildMain) + self._build = build + return # END Class GuiBuildFormatTab @@ -639,9 +644,11 @@ class GuiBuildFormatTab(QWidget): class GuiBuildContentTab(QWidget): - def __init__(self, buildMain): + def __init__(self, buildMain: GuiBuildSettings, build: BuildSettings): super().__init__(parent=buildMain) + self._build = build + return # END Class GuiBuildContentTab @@ -649,9 +656,11 @@ class GuiBuildContentTab(QWidget): class GuiBuildHTMLTab(QWidget): - def __init__(self, buildMain): + def __init__(self, buildMain: GuiBuildSettings, build: BuildSettings): super().__init__(parent=buildMain) + self._build = build + return # END Class GuiBuildHTMLTab @@ -659,9 +668,11 @@ class GuiBuildHTMLTab(QWidget): class GuiBuildMarkdownTab(QWidget): - def __init__(self, buildMain): + def __init__(self, buildMain: GuiBuildSettings, build: BuildSettings): super().__init__(parent=buildMain) + self._build = build + return # END Class GuiBuildMarkdownTab @@ -669,9 +680,11 @@ class GuiBuildMarkdownTab(QWidget): class GuiBuildODTTab(QWidget): - def __init__(self, buildMain): + def __init__(self, buildMain: GuiBuildSettings, build: BuildSettings): super().__init__(parent=buildMain) + self._build = build + return # END Class GuiBuildODTTab From 79a28b9e43fccf845799833b0f62a0aa8f81ba55 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 18 May 2023 17:41:29 +0200 Subject: [PATCH 25/74] Add handling of build settings and build name --- novelwriter/core/buildsettings.py | 3 +- novelwriter/core/projectdata.py | 1 - novelwriter/tools/manusbuild.py | 18 ++++++++++- novelwriter/tools/manussettings.py | 48 +++++++++++++++++++++++++----- 4 files changed, 60 insertions(+), 10 deletions(-) diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index a6880525..f914ce46 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -75,7 +75,7 @@ SETTINGS_LABELS = { "headings": QT_TRANSLATE_NOOP("Builds", "Headings"), "headings.fmtTitle": QT_TRANSLATE_NOOP("Builds", "Title Heading"), "headings.fmtChapter": QT_TRANSLATE_NOOP("Builds", "Chapter Heading"), - "headings.fmtUnnumbered": QT_TRANSLATE_NOOP("Builds", "Alt. Chapter Heading"), + "headings.fmtUnnumbered": QT_TRANSLATE_NOOP("Builds", "Unnumbered Heading"), "headings.fmtScene": QT_TRANSLATE_NOOP("Builds", "Scene Heading"), "headings.fmtSection": QT_TRANSLATE_NOOP("Builds", "Section Heading"), "headings.hideScene": QT_TRANSLATE_NOOP("Builds", "Hide Scene"), @@ -296,6 +296,7 @@ class BuildSettings: def pack(self): """Pack all content into a JSON compatible dictionary. """ + logger.debug("Collecting build setting for '%s'", self._name) return { "name": self._name, "uuid": self._uuid, diff --git a/novelwriter/core/projectdata.py b/novelwriter/core/projectdata.py index 58667ee3..ad376cd1 100644 --- a/novelwriter/core/projectdata.py +++ b/novelwriter/core/projectdata.py @@ -22,7 +22,6 @@ 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 . """ - from __future__ import annotations import uuid diff --git a/novelwriter/tools/manusbuild.py b/novelwriter/tools/manusbuild.py index 6f1fc7f8..53418d90 100644 --- a/novelwriter/tools/manusbuild.py +++ b/novelwriter/tools/manusbuild.py @@ -22,9 +22,13 @@ 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 . """ +from __future__ import annotations import logging +from typing import TYPE_CHECKING + +from PyQt5.QtCore import pyqtSlot from PyQt5.QtWidgets import ( QDialog, QGridLayout, QPushButton, QSplitter, QTextBrowser, QVBoxLayout, QWidget, qApp @@ -33,12 +37,15 @@ from PyQt5.QtWidgets import ( from novelwriter import CONFIG from novelwriter.tools.manussettings import GuiBuildSettings +if TYPE_CHECKING: + from novelwriter.guimain import GuiMain + logger = logging.getLogger(__name__) class GuiBuildManuscript(QDialog): - def __init__(self, mainGui): + def __init__(self, mainGui: GuiMain): super().__init__(parent=mainGui) self.mainGui = mainGui @@ -110,6 +117,7 @@ class GuiBuildManuscript(QDialog): # Private Slots ## + @pyqtSlot() def _createNewBuild(self): """Open the build settings dialog for a new build. """ @@ -121,9 +129,17 @@ class GuiBuildManuscript(QDialog): dlgSettings.raise_() qApp.processEvents() dlgSettings.loadContent() + dlgSettings.newSettingsReady.connect(self._processNewSettings) return + @pyqtSlot(dict) + def _processNewSettings(self, data: dict): + """ + """ + print(data) + return + ## # Internal Functions ## diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index 7ae2f770..dcca1851 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -29,11 +29,12 @@ import logging from typing import TYPE_CHECKING from PyQt5.QtGui import QIcon -from PyQt5.QtCore import QEvent, QSize, Qt, pyqtSlot +from PyQt5.QtCore import QEvent, QSize, Qt, pyqtSignal, pyqtSlot from PyQt5.QtWidgets import ( - QAbstractItemView, QDialog, QGridLayout, QHBoxLayout, QHeaderView, QLabel, - QLineEdit, QPushButton, QSplitter, QStackedWidget, QToolButton, - QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget + QAbstractButton, QAbstractItemView, QDialog, QDialogButtonBox, QGridLayout, + QHBoxLayout, QHeaderView, QLabel, QLineEdit, QPushButton, QSplitter, + QStackedWidget, QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, + QWidget ) from novelwriter import CONFIG @@ -58,6 +59,8 @@ class GuiBuildSettings(QDialog): BLD_MARKDOWN = 6 BLD_ODT = 7 + newSettingsReady = pyqtSignal(dict) + def __init__(self, mainGui: GuiMain, buildData: dict): super().__init__(parent=mainGui) @@ -129,10 +132,20 @@ class GuiBuildSettings(QDialog): self.toolStack.addWidget(self.buildTabMarkdown) self.toolStack.addWidget(self.buildTabODT) - # Main Settings - # ============= + # Main Settings + Buttons + # ======================= - self.lblTitle = QLabel() + self.lblBuildName = QLabel(self.tr("Name")) + self.editBuildName = QLineEdit() + self.dlgButtons = QDialogButtonBox( + QDialogButtonBox.Apply | QDialogButtonBox.Save | QDialogButtonBox.Close + ) + self.dlgButtons.clicked.connect(self._dialogButtonClicked) + + self.buttonBox = QHBoxLayout() + self.buttonBox.addWidget(self.lblBuildName) + self.buttonBox.addWidget(self.editBuildName) + self.buttonBox.addWidget(self.dlgButtons) # Assemble GUI # ============ @@ -140,9 +153,12 @@ class GuiBuildSettings(QDialog): self.mainBox = QHBoxLayout() self.mainBox.addWidget(self.optSideBar) self.mainBox.addWidget(self.toolStack) + self.mainBox.setContentsMargins(0, 0, 0, 0) self.outerBox = QVBoxLayout() self.outerBox.addLayout(self.mainBox) + self.outerBox.addLayout(self.buttonBox) + self.outerBox.setSpacing(CONFIG.pxInt(12)) self.setLayout(self.outerBox) @@ -156,6 +172,7 @@ class GuiBuildSettings(QDialog): def loadContent(self): """Populate the child widgets. """ + self.editBuildName.setText(self._build.name) self.optTabSelect.loadContent(self._build) return @@ -183,6 +200,22 @@ class GuiBuildSettings(QDialog): self.toolStack.setCurrentWidget(self.buildTabODT) return + @pyqtSlot("QAbstractButton*") + def _dialogButtonClicked(self, button: QAbstractButton): + """Handle button clicks from the dialog button box. + """ + role = self.dlgButtons.buttonRole(button) + if role in (QDialogButtonBox.ApplyRole, QDialogButtonBox.AcceptRole): + self._build.setName(self.editBuildName.text()) + self.newSettingsReady.emit(self._build.pack()) + + if role == QDialogButtonBox.AcceptRole: + self.accept() + elif role == QDialogButtonBox.RejectRole: + self.reject() + + return + ## # Events ## @@ -329,6 +362,7 @@ class GuiBuildFilterTab(QWidget): self.outerBox = QHBoxLayout() self.outerBox.addWidget(self.mainSplit) + self.outerBox.setContentsMargins(0, 0, 0, 0) self.setLayout(self.outerBox) From a212900554dd6f1d641d77e340bc382947f19e5c Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 18 May 2023 19:30:42 +0200 Subject: [PATCH 26/74] Add a Headings format edit form --- novelwriter/constants.py | 13 ++ novelwriter/core/buildsettings.py | 15 +- novelwriter/tools/manussettings.py | 241 ++++++++++++++++++++++++++--- 3 files changed, 244 insertions(+), 25 deletions(-) diff --git a/novelwriter/constants.py b/novelwriter/constants.py index a073307e..bb03cdfb 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -203,6 +203,19 @@ class nwLabels: # END Class nwLabels +class nwHeadingFormats: + + TITLE = "{Title}" + CH_NUM = "{Chapter}" + CH_WORD = "{Chapter:Word}" + CH_ROMU = "{Chapter:RomanU}" + CH_ROML = "{Chapter:RomanL}" + SC_NUM = "{Scene}" + SC_ABS = "{Scene:Abs}" + +# END Class nwHeadingFormats + + class nwQuotes: """Allowed quotation marks. Source: https://en.wikipedia.org/wiki/Quotation_mark diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index f914ce46..583c9cd1 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -31,6 +31,7 @@ from enum import Enum from PyQt5.QtCore import QT_TRANSLATE_NOOP from novelwriter.common import checkUuid, isHandle +from novelwriter.constants import nwHeadingFormats from novelwriter.core.item import NWItem from novelwriter.core.project import NWProject @@ -45,13 +46,13 @@ SETTINGS_TEMPLATE = { "filter.includeNovel": (bool, True), "filter.includeNotes": (bool, False), "filter.includeInactive": (bool, False), - "headings.fmtTitle": (str, "%title%"), - "headings.fmtChapter": (str, "%title%"), - "headings.fmtUnnumbered": (str, "%title%"), - "headings.fmtScene": (str, "%title%"), - "headings.fmtSection": (str, "%title%"), + "headings.fmtTitle": (str, nwHeadingFormats.TITLE), + "headings.fmtChapter": (str, nwHeadingFormats.TITLE), + "headings.fmtUnnumbered": (str, nwHeadingFormats.TITLE), + "headings.fmtScene": (str, "* * *"), + "headings.fmtSection": (str, ""), "headings.hideScene": (bool, False), - "headings.hideSection": (bool, False), + "headings.hideSection": (bool, True), "text.includeSynopsis": (bool, False), "text.includeComments": (bool, False), "text.includeKeywords": (bool, False), @@ -222,8 +223,8 @@ class BuildSettings: return False if len(definition) == 4: value = min(max(value, definition[2]), definition[3]) + self._changed = value != self._settings[key] self._settings[key] = value - self._changed = True logger.debug(f"Build Setting '{key}' set to: {value}") return True diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index dcca1851..c7cfe5b3 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -28,16 +28,17 @@ import logging from typing import TYPE_CHECKING -from PyQt5.QtGui import QIcon +from PyQt5.QtGui import QColor, QIcon, QSyntaxHighlighter, QTextCharFormat, QTextDocument from PyQt5.QtCore import QEvent, QSize, Qt, pyqtSignal, pyqtSlot from PyQt5.QtWidgets import ( QAbstractButton, QAbstractItemView, QDialog, QDialogButtonBox, QGridLayout, - QHBoxLayout, QHeaderView, QLabel, QLineEdit, QPushButton, QSplitter, + QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMenu, QPlainTextEdit, QPushButton, QSplitter, QStackedWidget, QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget ) from novelwriter import CONFIG +from novelwriter.constants import nwHeadingFormats from novelwriter.core.buildsettings import BuildSettings, FilterMode from novelwriter.extensions.switch import NSwitch from novelwriter.extensions.switchbox import NSwitchBox @@ -45,6 +46,7 @@ from novelwriter.extensions.pagedsidebar import NPagedSideBar if TYPE_CHECKING: from novelwriter.guimain import GuiMain + from novelwriter.gui.theme import GuiTheme logger = logging.getLogger(__name__) @@ -96,14 +98,14 @@ class GuiBuildSettings(QDialog): self.optSideBar.setMaximumWidth(mPx) self.optSideBar.setLabelColor(self.mainTheme.helpText) - self.optSideBar.addLabel(self.tr("Options")) + self.optSideBar.addLabel(self.tr("Content")) self.optSideBar.addButton(self.tr("Filters"), self.OPT_FILTERS) self.optSideBar.addButton(self.tr("Headings"), self.OPT_HEADINGS) self.optSideBar.addButton(self.tr("Format"), self.OPT_FORMAT) self.optSideBar.addButton(self.tr("Content"), self.OPT_CONTENT) self.optSideBar.addSeparator() - self.optSideBar.addLabel(self.tr("Build")) + self.optSideBar.addLabel(self.tr("Output")) self.optSideBar.addButton(self.tr("HTML"), self.BLD_HTML) self.optSideBar.addButton(self.tr("Markdown"), self.BLD_MARKDOWN) self.optSideBar.addButton(self.tr("Open Document"), self.BLD_ODT) @@ -173,7 +175,8 @@ class GuiBuildSettings(QDialog): """Populate the child widgets. """ self.editBuildName.setText(self._build.name) - self.optTabSelect.loadContent(self._build) + self.optTabSelect.loadContent() + self.optTabHeadings.loadContent() return ## @@ -368,10 +371,9 @@ class GuiBuildFilterTab(QWidget): return - def loadContent(self, build: BuildSettings): + def loadContent(self): """Populate the widgets. """ - self._build = build self._populateTree() self._populateFilters() return @@ -540,6 +542,12 @@ class GuiBuildFilterTab(QWidget): class GuiBuildHeadingsTab(QWidget): + EDIT_TITLE = 1 + EDIT_CHAPTER = 2 + EDIT_UNNUM = 3 + EDIT_SCENE = 4 + EDIT_SECTION = 5 + def __init__(self, buildMain: GuiBuildSettings, build: BuildSettings): super().__init__(parent=buildMain) @@ -548,6 +556,7 @@ class GuiBuildHeadingsTab(QWidget): self.theProject = buildMain.mainGui.theProject self._build = build + self._editing = 0 iPx = self.mainTheme.baseIconSize vSp = CONFIG.pxInt(12) @@ -564,6 +573,7 @@ class GuiBuildHeadingsTab(QWidget): self.fmtTitle.setEnabled(False) self.btnTitle = QToolButton() self.btnTitle.setIcon(self.mainTheme.getIcon("edit")) + self.btnTitle.clicked.connect(lambda: self._editHeading(self.EDIT_TITLE)) wrapTitle = QHBoxLayout() wrapTitle.addWidget(self.fmtTitle) @@ -579,6 +589,7 @@ class GuiBuildHeadingsTab(QWidget): self.fmtChapter.setEnabled(False) self.btnChapter = QToolButton() self.btnChapter.setIcon(self.mainTheme.getIcon("edit")) + self.btnChapter.clicked.connect(lambda: self._editHeading(self.EDIT_CHAPTER)) wrapChapter = QHBoxLayout() wrapChapter.addWidget(self.fmtChapter) @@ -589,19 +600,20 @@ class GuiBuildHeadingsTab(QWidget): self.formatBox.addLayout(wrapChapter, 1, 1, Qt.AlignLeft) # Unnumbered Chapter Heading - self.lblUnChapter = QLabel(BuildSettings.getLabel("headings.fmtUnnumbered")) - self.fmtUnChapter = QLineEdit("") - self.fmtUnChapter.setEnabled(False) - self.btnUnChapter = QToolButton() - self.btnUnChapter.setIcon(self.mainTheme.getIcon("edit")) + self.lblUnnumbered = QLabel(BuildSettings.getLabel("headings.fmtUnnumbered")) + self.fmtUnnumbered = QLineEdit("") + self.fmtUnnumbered.setEnabled(False) + self.btnUnnumbered = QToolButton() + self.btnUnnumbered.setIcon(self.mainTheme.getIcon("edit")) + self.btnUnnumbered.clicked.connect(lambda: self._editHeading(self.EDIT_UNNUM)) - wrapUnChapter = QHBoxLayout() - wrapUnChapter.addWidget(self.fmtUnChapter) - wrapUnChapter.addWidget(self.btnUnChapter) - wrapUnChapter.setSpacing(bSp) + wrapUnnumbered = QHBoxLayout() + wrapUnnumbered.addWidget(self.fmtUnnumbered) + wrapUnnumbered.addWidget(self.btnUnnumbered) + wrapUnnumbered.setSpacing(bSp) - self.formatBox.addWidget(self.lblUnChapter, 2, 0, Qt.AlignLeft) - self.formatBox.addLayout(wrapUnChapter, 2, 1, Qt.AlignLeft) + self.formatBox.addWidget(self.lblUnnumbered, 2, 0, Qt.AlignLeft) + self.formatBox.addLayout(wrapUnnumbered, 2, 1, Qt.AlignLeft) # Scene Heading self.lblScene = QLabel(BuildSettings.getLabel("headings.fmtScene")) @@ -609,7 +621,9 @@ class GuiBuildHeadingsTab(QWidget): self.fmtScene.setEnabled(False) self.btnScene = QToolButton() self.btnScene.setIcon(self.mainTheme.getIcon("edit")) + self.btnScene.clicked.connect(lambda: self._editHeading(self.EDIT_SCENE)) self.swtScene = NSwitch(width=2*iPx, height=iPx) + self.swtScene.toggled.connect(self._hideScene) wrapScene = QHBoxLayout() wrapScene.addWidget(self.fmtScene) @@ -631,7 +645,9 @@ class GuiBuildHeadingsTab(QWidget): self.fmtSection.setEnabled(False) self.btnSection = QToolButton() self.btnSection.setIcon(self.mainTheme.getIcon("edit")) + self.btnSection.clicked.connect(lambda: self._editHeading(self.EDIT_SECTION)) self.swtSection = NSwitch(width=2*iPx, height=iPx) + self.swtSection.toggled.connect(self._hideSection) wrapSection = QHBoxLayout() wrapSection.addWidget(self.fmtSection) @@ -647,17 +663,165 @@ class GuiBuildHeadingsTab(QWidget): self.formatBox.addLayout(wrapSection, 4, 1, Qt.AlignLeft) self.formatBox.addLayout(wrapSectionHide, 4, 2, Qt.AlignLeft) + # Edit Form + # ========= + + self.lblEditForm = QLabel(self.tr("Editing: {0}").format(self.tr("None"))) + + self.editTextBox = QPlainTextEdit() + self.editTextBox.setFixedHeight(5*iPx) + self.editTextBox.setEnabled(False) + + self.formSyntax = GuiHeadingSyntax(self.editTextBox.document(), self.mainTheme) + + self.menuInsert = QMenu() + self.aInsTitle = self.menuInsert.addAction(self.tr("Title")) + self.aInsChNum = self.menuInsert.addAction(self.tr("Chapter Number")) + self.aInsChWord = self.menuInsert.addAction(self.tr("Chapter Number (Word)")) + self.aInsChRomU = self.menuInsert.addAction(self.tr("Chapter Number (Upper Case Roman)")) + self.aInsChRomL = self.menuInsert.addAction(self.tr("Chapter Number (Lower Case Roman)")) + self.aInsScNum = self.menuInsert.addAction(self.tr("Scene Number (In Chapter)")) + self.aInsScAbs = self.menuInsert.addAction(self.tr("Scene Number (Absolute)")) + + self.aInsTitle.triggered.connect(lambda: self._insertIntoForm(nwHeadingFormats.TITLE)) + self.aInsChNum.triggered.connect(lambda: self._insertIntoForm(nwHeadingFormats.CH_NUM)) + self.aInsChWord.triggered.connect(lambda: self._insertIntoForm(nwHeadingFormats.CH_WORD)) + self.aInsChRomU.triggered.connect(lambda: self._insertIntoForm(nwHeadingFormats.CH_ROMU)) + self.aInsChRomL.triggered.connect(lambda: self._insertIntoForm(nwHeadingFormats.CH_ROML)) + self.aInsScNum.triggered.connect(lambda: self._insertIntoForm(nwHeadingFormats.SC_NUM)) + self.aInsScAbs.triggered.connect(lambda: self._insertIntoForm(nwHeadingFormats.SC_ABS)) + + self.btnInsert = QPushButton(self.tr("Insert")) + self.btnInsert.setMenu(self.menuInsert) + + self.btnApply = QPushButton(self.tr("Apply")) + self.btnApply.clicked.connect(self._saveFormat) + + self.formButtonBox = QHBoxLayout() + self.formButtonBox.addStretch(1) + self.formButtonBox.addWidget(self.btnInsert) + self.formButtonBox.addWidget(self.btnApply) + + self.editFormBox = QVBoxLayout() + self.editFormBox.addWidget(self.lblEditForm) + self.editFormBox.addWidget(self.editTextBox) + self.editFormBox.addLayout(self.formButtonBox) + # Assemble # ======== self.outerBox = QVBoxLayout() self.outerBox.addLayout(self.formatBox) + self.outerBox.addSpacing(CONFIG.pxInt(16)) + self.outerBox.addLayout(self.editFormBox) self.outerBox.addStretch(1) self.setLayout(self.outerBox) return + def loadContent(self): + """Populate the widgets. + """ + self.fmtTitle.setText(str(self._build.getValue("headings.fmtTitle"))) + self.fmtChapter.setText(str(self._build.getValue("headings.fmtChapter"))) + self.fmtUnnumbered.setText(str(self._build.getValue("headings.fmtUnnumbered"))) + self.fmtScene.setText(str(self._build.getValue("headings.fmtScene"))) + self.fmtSection.setText(str(self._build.getValue("headings.fmtSection"))) + self.swtScene.setChecked(bool(self._build.getValue("headings.hideScene"))) + self.swtSection.setChecked(bool(self._build.getValue("headings.hideSection"))) + return + + ## + # Internal Functions + ## + + def _insertIntoForm(self, text: str): + """Insert formatting text from the dropdown menu. + """ + if self._editing > 0: + cursor = self.editTextBox.textCursor() + cursor.insertText(text) + self.editTextBox.setFocus() + return + + def _editHeading(self, heading: int): + """Populate the form with a specific heading format. + """ + self._editing = heading + self.editTextBox.setEnabled(True) + if heading == self.EDIT_TITLE: + text = self.fmtTitle.text() + label = self._build.getLabel("headings.fmtTitle") + elif heading == self.EDIT_CHAPTER: + text = self.fmtChapter.text() + label = self._build.getLabel("headings.fmtChapter") + elif heading == self.EDIT_UNNUM: + text = self.fmtUnnumbered.text() + label = self._build.getLabel("headings.fmtUnnumbered") + elif heading == self.EDIT_SCENE: + text = self.fmtScene.text() + label = self._build.getLabel("headings.fmtScene") + elif heading == self.EDIT_SECTION: + text = self.fmtSection.text() + label = self._build.getLabel("headings.fmtSection") + else: + self._editing = 0 + self.editTextBox.setEnabled(False) + text = "" + label = self.tr("None") + + self.editTextBox.setPlainText(text.replace("//", "\n")) + self.lblEditForm.setText(self.tr("Editing: {0}").format(label)) + + return + + ## + # Private Slots + ## + + def _saveFormat(self): + """Save the format from the edit text box. + """ + heading = self._editing + text = self.editTextBox.toPlainText().replace("\n", "//") + if heading == self.EDIT_TITLE: + self.fmtTitle.setText(text) + self._build.setValue("headings.fmtTitle", text) + elif heading == self.EDIT_CHAPTER: + self.fmtChapter.setText(text) + self._build.setValue("headings.fmtChapter", text) + elif heading == self.EDIT_UNNUM: + self.fmtUnnumbered.setText(text) + self._build.setValue("headings.fmtUnnumbered", text) + elif heading == self.EDIT_SCENE: + self.fmtScene.setText(text) + self._build.setValue("headings.fmtScene", text) + elif heading == self.EDIT_SECTION: + self.fmtSection.setText(text) + self._build.setValue("headings.fmtSection", text) + else: + return + + self._editHeading(0) + self.editTextBox.clear() + + return + + @pyqtSlot(bool) + def _hideScene(self, status): + """Hide scene heading + """ + self._build.setValue("headings.hideScene", status) + return + + @pyqtSlot(bool) + def _hideSection(self, status): + """Hide section heading + """ + self._build.setValue("headings.hideSection", status) + return + # END Class GuiBuildHeadingsTab @@ -719,3 +883,44 @@ class GuiBuildODTTab(QWidget): return # END Class GuiBuildODTTab + + +class GuiHeadingSyntax(QSyntaxHighlighter): + + def __init__(self, document: QTextDocument, mainTheme: GuiTheme): + super().__init__(document) + + self._valid = [ + nwHeadingFormats.TITLE.lower(), + nwHeadingFormats.CH_NUM.lower(), + nwHeadingFormats.CH_WORD.lower(), + nwHeadingFormats.CH_ROMU.lower(), + nwHeadingFormats.CH_ROML.lower(), + nwHeadingFormats.SC_NUM.lower(), + nwHeadingFormats.SC_ABS.lower(), + ] + + self._fmtSymbol = QTextCharFormat() + self._fmtSymbol.setForeground(QColor(*mainTheme.colHead)) + + self._fmtFormat = QTextCharFormat() + self._fmtFormat.setForeground(QColor(*mainTheme.colEmph)) + + return + + def highlightBlock(self, text: str): + """Add syntax highlighting to the text block. + """ + check = text.lower() + for heading in self._valid: + pos = check.find(heading) + if pos >= 0: + chars = len(heading) + self.setFormat(pos, chars, self._fmtSymbol) + self.setFormat(pos + 1, chars - 2, self._fmtFormat) + ddots = heading.find(":") + if ddots > 0: + self.setFormat(pos + ddots, 1, self._fmtSymbol) + return + +# END Class GuiHeadingSyntax From 0a2355488ec2ff41c6660e6606c97dc3e36c7ad9 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Mon, 22 May 2023 16:25:33 +0200 Subject: [PATCH 27/74] Simplify build settings dialog --- novelwriter/constants.py | 12 +++--- novelwriter/core/buildsettings.py | 53 +++++++++++++------------ novelwriter/tools/manusbuild.py | 6 ++- novelwriter/tools/manussettings.py | 63 ++++++------------------------ 4 files changed, 50 insertions(+), 84 deletions(-) diff --git a/novelwriter/constants.py b/novelwriter/constants.py index bb03cdfb..445f8b71 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -205,13 +205,13 @@ class nwLabels: class nwHeadingFormats: - TITLE = "{Title}" - CH_NUM = "{Chapter}" + TITLE = "{Title}" + CH_NUM = "{Chapter}" CH_WORD = "{Chapter:Word}" - CH_ROMU = "{Chapter:RomanU}" - CH_ROML = "{Chapter:RomanL}" - SC_NUM = "{Scene}" - SC_ABS = "{Scene:Abs}" + CH_ROMU = "{Chapter:URoman}" + CH_ROML = "{Chapter:LRoman}" + SC_NUM = "{Scene}" + SC_ABS = "{Scene:Abs}" # END Class nwHeadingFormats diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index 583c9cd1..a8d95692 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -22,6 +22,7 @@ General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . """ +from __future__ import annotations import uuid import logging @@ -59,7 +60,7 @@ SETTINGS_TEMPLATE = { "text.includeBody": (bool, True), "format.buildLang": (str, "en_GB"), "format.textFont": (str, ""), - "format.textSize": (str, ""), + "format.textSize": (int, 12), "format.lineHeight": (float, 1.15, 0.75, 3.0), "format.justifyText": (bool, False), "format.stripUnicode": (bool, False), @@ -119,7 +120,7 @@ class BuildSettings: def __init__(self): self._name = "" - self._uuid = "" + self._uuid = str(uuid.uuid4()) self._skipRoot = set() self._excluded = set() self._included = set() @@ -132,15 +133,15 @@ class BuildSettings: ## @property - def name(self): + def name(self) -> str: return self._name @property - def buildID(self): + def buildID(self) -> str: return self._uuid @property - def changed(self): + def changed(self) -> bool: return self._changed ## @@ -148,12 +149,12 @@ class BuildSettings: ## @staticmethod - def getLabel(key): + def getLabel(key: str) -> str: """Extract the label for a specific item. """ return SETTINGS_LABELS.get(key, "ERROR") - def getValue(self, key): + def getValue(self, key: str) -> str | int | bool | float: """Get the value for a specific item, or return the default. """ return self._settings.get(key, SETTINGS_TEMPLATE.get(key, (None, None)[1])) @@ -162,13 +163,13 @@ class BuildSettings: # Setters ## - def setName(self, name): + def setName(self, name: str): """Set the build setting display name. """ self._name = str(name) return - def setBuildID(self, value): + def setBuildID(self, value: str | uuid.UUID): """Set a UUID build ID. """ value = checkUuid(value, "") @@ -178,7 +179,7 @@ class BuildSettings: self._uuid = value return - def setFiltered(self, tHandle): + def setFiltered(self, tHandle: str): """Set an item as filtered. """ self._excluded.discard(tHandle) @@ -186,7 +187,7 @@ class BuildSettings: self._changed = True return - def setIncluded(self, tHandle): + def setIncluded(self, tHandle: str): """Set an item as explicitly included. """ self._excluded.discard(tHandle) @@ -194,7 +195,7 @@ class BuildSettings: self._changed = True return - def setExcluded(self, tHandle): + def setExcluded(self, tHandle: str): """Set an item as explicitly excluded. """ self._excluded.add(tHandle) @@ -202,7 +203,7 @@ class BuildSettings: self._changed = True return - def setSkipRoot(self, tHandle, state): + def setSkipRoot(self, tHandle: str, state: bool): """Set a specific root folder as skipped or not. """ if state is True: @@ -213,7 +214,7 @@ class BuildSettings: self._changed = True return - def setValue(self, key, value): + def setValue(self, key: str, value: str | int | bool | float) -> bool: """Set a specific value for a build setting. """ if key not in SETTINGS_TEMPLATE: @@ -232,32 +233,34 @@ class BuildSettings: # Methods ## - def isFiltered(self, tHandle): + def isFiltered(self, tHandle: str) -> bool: return tHandle not in self._included and tHandle not in self._excluded - def isIncluded(self, tHandle): + def isIncluded(self, tHandle: str) -> bool: return tHandle in self._included - def isExcluded(self, tHandle): + def isExcluded(self, tHandle: str) -> bool: return tHandle in self._excluded - def isRootAllowed(self, tHandle): + def isRootAllowed(self, tHandle: str) -> bool: return tHandle not in self._skipRoot - def buildItemFilter(self, project): + def buildItemFilter(self, project: NWProject) -> dict: """Return a dictionary of item handles with filter decissions applied. """ - result = {} + result: dict[str, tuple[bool, FilterMode]] = {} if not isinstance(project, NWProject): return result - incNovel = self.getValue("filter.includeNovel") or False - incNotes = self.getValue("filter.includeNotes") or False - incInactive = self.getValue("filter.includeInactive") or False + incNovel = bool(self.getValue("filter.includeNovel")) + incNotes = bool(self.getValue("filter.includeNotes")) + incInactive = bool(self.getValue("filter.includeInactive")) for item in project.tree: tHandle = item.itemHandle + if not tHandle: + continue if not isinstance(item, NWItem): result[tHandle] = (False, FilterMode.UNKNOWN) continue @@ -294,7 +297,7 @@ class BuildSettings: self._changed = False return - def pack(self): + def pack(self) -> dict: """Pack all content into a JSON compatible dictionary. """ logger.debug("Collecting build setting for '%s'", self._name) @@ -307,7 +310,7 @@ class BuildSettings: "skipRoot": list(self._skipRoot), } - def unpack(self, data): + def unpack(self, data: dict): """Unpack a dictionary and populate the class. """ included = data.get("included", []) diff --git a/novelwriter/tools/manusbuild.py b/novelwriter/tools/manusbuild.py index 53418d90..912532b7 100644 --- a/novelwriter/tools/manusbuild.py +++ b/novelwriter/tools/manusbuild.py @@ -35,6 +35,7 @@ from PyQt5.QtWidgets import ( ) from novelwriter import CONFIG +from novelwriter.core.buildsettings import BuildSettings from novelwriter.tools.manussettings import GuiBuildSettings if TYPE_CHECKING: @@ -121,9 +122,10 @@ class GuiBuildManuscript(QDialog): def _createNewBuild(self): """Open the build settings dialog for a new build. """ - data = {"name": self.tr("My Manuscript")} + build = BuildSettings() + build.setName(self.tr("My Manuscript")) - dlgSettings = GuiBuildSettings(self.mainGui, data) + dlgSettings = GuiBuildSettings(self.mainGui, build) dlgSettings.setModal(False) dlgSettings.show() dlgSettings.raise_() diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index c7cfe5b3..27cfdb0f 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -57,13 +57,11 @@ class GuiBuildSettings(QDialog): OPT_HEADINGS = 2 OPT_FORMAT = 3 OPT_CONTENT = 4 - BLD_HTML = 5 - BLD_MARKDOWN = 6 - BLD_ODT = 7 + OPT_OUTPUT = 5 newSettingsReady = pyqtSignal(dict) - def __init__(self, mainGui: GuiMain, buildData: dict): + def __init__(self, mainGui: GuiMain, build: BuildSettings): super().__init__(parent=mainGui) logger.debug("Initialising GuiBuildSettings ...") @@ -73,8 +71,7 @@ class GuiBuildSettings(QDialog): self.mainTheme = mainGui.mainTheme self.theProject = mainGui.theProject - self._build = BuildSettings() - self._build.unpack(buildData) + self._build = build self.setWindowTitle(self.tr("Manuscript Build Settings")) self.setMinimumWidth(CONFIG.pxInt(700)) @@ -98,17 +95,12 @@ class GuiBuildSettings(QDialog): self.optSideBar.setMaximumWidth(mPx) self.optSideBar.setLabelColor(self.mainTheme.helpText) - self.optSideBar.addLabel(self.tr("Content")) + self.optSideBar.addLabel(self.tr("Options")) self.optSideBar.addButton(self.tr("Filters"), self.OPT_FILTERS) self.optSideBar.addButton(self.tr("Headings"), self.OPT_HEADINGS) self.optSideBar.addButton(self.tr("Format"), self.OPT_FORMAT) self.optSideBar.addButton(self.tr("Content"), self.OPT_CONTENT) - self.optSideBar.addSeparator() - - self.optSideBar.addLabel(self.tr("Output")) - self.optSideBar.addButton(self.tr("HTML"), self.BLD_HTML) - self.optSideBar.addButton(self.tr("Markdown"), self.BLD_MARKDOWN) - self.optSideBar.addButton(self.tr("Open Document"), self.BLD_ODT) + self.optSideBar.addButton(self.tr("Output"), self.OPT_OUTPUT) self.optSideBar.buttonClicked.connect(self._stackPageSelected) @@ -120,9 +112,7 @@ class GuiBuildSettings(QDialog): self.optTabHeadings = GuiBuildHeadingsTab(self, self._build) self.optTabFormat = GuiBuildFormatTab(self, self._build) self.optTabContent = GuiBuildContentTab(self, self._build) - self.buildTabHTML = GuiBuildHTMLTab(self, self._build) - self.buildTabMarkdown = GuiBuildMarkdownTab(self, self._build) - self.buildTabODT = GuiBuildODTTab(self, self._build) + self.optTabOutput = GuiBuildOutputTab(self, self._build) # Add Tabs self.toolStack = QStackedWidget(self) @@ -130,9 +120,7 @@ class GuiBuildSettings(QDialog): self.toolStack.addWidget(self.optTabHeadings) self.toolStack.addWidget(self.optTabFormat) self.toolStack.addWidget(self.optTabContent) - self.toolStack.addWidget(self.buildTabHTML) - self.toolStack.addWidget(self.buildTabMarkdown) - self.toolStack.addWidget(self.buildTabODT) + self.toolStack.addWidget(self.optTabOutput) # Main Settings + Buttons # ======================= @@ -195,12 +183,8 @@ class GuiBuildSettings(QDialog): self.toolStack.setCurrentWidget(self.optTabFormat) elif pageId == self.OPT_CONTENT: self.toolStack.setCurrentWidget(self.optTabContent) - elif pageId == self.BLD_HTML: - self.toolStack.setCurrentWidget(self.buildTabHTML) - elif pageId == self.BLD_MARKDOWN: - self.toolStack.setCurrentWidget(self.buildTabMarkdown) - elif pageId == self.BLD_ODT: - self.toolStack.setCurrentWidget(self.buildTabODT) + elif pageId == self.OPT_OUTPUT: + self.toolStack.setCurrentWidget(self.optTabOutput) return @pyqtSlot("QAbstractButton*") @@ -212,6 +196,7 @@ class GuiBuildSettings(QDialog): self._build.setName(self.editBuildName.text()) self.newSettingsReady.emit(self._build.pack()) + self._saveSettings() if role == QDialogButtonBox.AcceptRole: self.accept() elif role == QDialogButtonBox.RejectRole: @@ -849,7 +834,7 @@ class GuiBuildContentTab(QWidget): # END Class GuiBuildContentTab -class GuiBuildHTMLTab(QWidget): +class GuiBuildOutputTab(QWidget): def __init__(self, buildMain: GuiBuildSettings, build: BuildSettings): super().__init__(parent=buildMain) @@ -858,31 +843,7 @@ class GuiBuildHTMLTab(QWidget): return -# END Class GuiBuildHTMLTab - - -class GuiBuildMarkdownTab(QWidget): - - def __init__(self, buildMain: GuiBuildSettings, build: BuildSettings): - super().__init__(parent=buildMain) - - self._build = build - - return - -# END Class GuiBuildMarkdownTab - - -class GuiBuildODTTab(QWidget): - - def __init__(self, buildMain: GuiBuildSettings, build: BuildSettings): - super().__init__(parent=buildMain) - - self._build = build - - return - -# END Class GuiBuildODTTab +# END Class GuiBuildOutputTab class GuiHeadingSyntax(QSyntaxHighlighter): From 854cc77220d139b7812b987446027753d7e98bb6 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Mon, 22 May 2023 18:38:58 +0200 Subject: [PATCH 28/74] Add list of builds to manuscript build dialog --- novelwriter/constants.py | 1 + novelwriter/core/buildsettings.py | 113 ++++++++++++++++++++++++++--- novelwriter/tools/manusbuild.py | 97 ++++++++++++++++++++----- novelwriter/tools/manussettings.py | 4 +- 4 files changed, 184 insertions(+), 31 deletions(-) diff --git a/novelwriter/constants.py b/novelwriter/constants.py index 445f8b71..da807c6d 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -85,6 +85,7 @@ class nwFiles: OPTS_FILE = "guiOptions.json" RECENT_FILE = "recentProjects.json" BUILD_CACHE = "prevBuild.json" + BUILDS_FILE = "builds.json" # END Class nwFiles diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index a8d95692..5ecc6f32 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -4,7 +4,8 @@ novelWriter – Build Settings Class A class to hold build settings for the build tool File History: -Created: 2023-02-14 [2.1b1] +Created: 2023-02-14 [2.1b1] BuildSettings +Created: 2023-05-22 [2.1b1] BuildCollection This file is a part of novelWriter Copyright 2018–2023, Veronica Berglyd Olsen @@ -24,17 +25,21 @@ along with this program. If not, see . """ from __future__ import annotations +import json import uuid import logging from enum import Enum +from typing import Iterable +from pathlib import Path from PyQt5.QtCore import QT_TRANSLATE_NOOP -from novelwriter.common import checkUuid, isHandle -from novelwriter.constants import nwHeadingFormats +from novelwriter.common import checkUuid, isHandle, jsonEncode +from novelwriter.constants import nwFiles, nwHeadingFormats from novelwriter.core.item import NWItem from novelwriter.core.project import NWProject +from novelwriter.error import logException logger = logging.getLogger(__name__) @@ -305,18 +310,21 @@ class BuildSettings: "name": self._name, "uuid": self._uuid, "settings": self._settings.copy(), - "included": list(self._included), - "excluded": list(self._excluded), - "skipRoot": list(self._skipRoot), + "content": { + "included": list(self._included), + "excluded": list(self._excluded), + "skipRoot": list(self._skipRoot), + } } def unpack(self, data: dict): """Unpack a dictionary and populate the class. """ - included = data.get("included", []) - excluded = data.get("excluded", []) - skipRoot = data.get("skipRoot", []) settings = data.get("settings", {}) + content = data.get("content", {}) + included = content.get("included", []) + excluded = content.get("excluded", []) + skipRoot = content.get("skipRoot", []) self.setName(data.get("name", "")) self.setBuildID(data.get("uuid", "")) @@ -337,3 +345,90 @@ class BuildSettings: return # END Class BuildSettings + + +class BuildCollection: + + def __init__(self, project: NWProject): + self._project = project + self._builds = {} + return + + def getBuild(self, buildID: str) -> BuildSettings | None: + """ + """ + if buildID not in self._builds: + return None + build = BuildSettings() + build.unpack(self._builds[buildID]) + return build + + def setBuild(self, build: BuildSettings) -> bool: + """ + """ + if not isinstance(build, BuildSettings): + return False + buildID = build.buildID + self._builds[buildID] = build.pack() + return True + + def builds(self) -> Iterable[tuple[str, str]]: + """ + """ + for buildID in self._builds: + yield buildID, self._builds[buildID].get("name", "") + return + + def loadCollection(self) -> bool: + """ + """ + buildsFile = self._project.storage.getMetaFile(nwFiles.BUILDS_FILE) + if not isinstance(buildsFile, Path): + return False + + data = {} + if buildsFile.exists(): + logger.debug("Loading builds file") + try: + with open(buildsFile, mode="r", encoding="utf-8") as inFile: + data = json.load(inFile) + except Exception: + logger.error("Failed to load builds file") + logException() + return False + + if not isinstance(data, dict): + logger.error("Builds file is not a JSON object") + return False + + builds = data.get("novelWriter.builds", None) + if not isinstance(builds, dict): + logger.error("No novelWriter.builds in the builds file") + return False + + for key, entry in builds.items(): + if isinstance(entry, dict): + self._builds[key] = entry + + return True + + def saveCollection(self) -> bool: + """ + """ + buildsFile = self._project.storage.getMetaFile(nwFiles.BUILDS_FILE) + if not isinstance(buildsFile, Path): + return False + + logger.debug("Saving builds file") + try: + data = {"novelWriter.builds": self._builds} + with open(buildsFile, mode="w+", encoding="utf-8") as outFile: + outFile.write(jsonEncode(data, nmax=4)) + except Exception: + logger.error("Failed to save builds file") + logException() + return False + + return True + +# END Class BuildCollection diff --git a/novelwriter/tools/manusbuild.py b/novelwriter/tools/manusbuild.py index 912532b7..cdebd0ea 100644 --- a/novelwriter/tools/manusbuild.py +++ b/novelwriter/tools/manusbuild.py @@ -28,14 +28,14 @@ import logging from typing import TYPE_CHECKING -from PyQt5.QtCore import pyqtSlot +from PyQt5.QtCore import Qt, pyqtSlot from PyQt5.QtWidgets import ( - QDialog, QGridLayout, QPushButton, QSplitter, QTextBrowser, QVBoxLayout, - QWidget, qApp + QDialog, QHBoxLayout, QListWidget, QListWidgetItem, QPushButton, QSplitter, + QTextBrowser, QVBoxLayout, QWidget, qApp ) from novelwriter import CONFIG -from novelwriter.core.buildsettings import BuildSettings +from novelwriter.core.buildsettings import BuildCollection, BuildSettings from novelwriter.tools.manussettings import GuiBuildSettings if TYPE_CHECKING: @@ -53,6 +53,9 @@ class GuiBuildManuscript(QDialog): self.mainTheme = mainGui.mainTheme self.theProject = mainGui.theProject + self._builds = BuildCollection(self.theProject) + self._buildMap = {} + self.setWindowTitle(self.tr("Build Manuscript")) self.setMinimumWidth(CONFIG.pxInt(600)) self.setMinimumHeight(CONFIG.pxInt(500)) @@ -69,19 +72,32 @@ class GuiBuildManuscript(QDialog): # Controls # ======== - self.btnNew = QPushButton(self.tr("New Build")) + self.buildList = QListWidget() + + self.btnNew = QPushButton(self.tr("New")) self.btnNew.clicked.connect(self._createNewBuild) - self.optsGrid = QGridLayout() - self.optsGrid.addWidget(self.btnNew, 0, 0) + self.btnEdit = QPushButton(self.tr("Edit")) + self.btnEdit.clicked.connect(self._editSelectedBuild) + + self.btnDel = QPushButton(self.tr("Delete")) + + self.buttonBox = QHBoxLayout() + self.buttonBox.addWidget(self.btnNew) + self.buttonBox.addWidget(self.btnEdit) + self.buttonBox.addWidget(self.btnDel) self.manPreview = GuiManuscriptPreview(self) # Assemble GUI # ============ + self.controlBox = QVBoxLayout() + self.controlBox.addWidget(self.buildList) + self.controlBox.addLayout(self.buttonBox) + self.optsWidget = QWidget() - self.optsWidget.setLayout(self.optsGrid) + self.optsWidget.setLayout(self.controlBox) self.mainSplit = QSplitter() self.mainSplit.addWidget(self.optsWidget) @@ -101,6 +117,8 @@ class GuiBuildManuscript(QDialog): def loadContent(self): """ """ + self._builds.loadCollection() + self._updateBuildsList() return ## @@ -124,22 +142,27 @@ class GuiBuildManuscript(QDialog): """ build = BuildSettings() build.setName(self.tr("My Manuscript")) - - dlgSettings = GuiBuildSettings(self.mainGui, build) - dlgSettings.setModal(False) - dlgSettings.show() - dlgSettings.raise_() - qApp.processEvents() - dlgSettings.loadContent() - dlgSettings.newSettingsReady.connect(self._processNewSettings) - + self._openSettingsDialog(build) return - @pyqtSlot(dict) - def _processNewSettings(self, data: dict): + @pyqtSlot() + def _editSelectedBuild(self): + """Edit the currently selected build settings entry. """ + bItems = self.buildList.selectedItems() + if bItems: + build = self._builds.getBuild(bItems[0].data(Qt.UserRole)) + if isinstance(build, BuildSettings): + self._openSettingsDialog(build) + return + + @pyqtSlot(BuildSettings) + def _processNewSettings(self, build: BuildSettings): + """Process new build settings from the settings dialog. """ - print(data) + self._builds.setBuild(build) + self._builds.saveCollection() + self._updateBuildItem(build) return ## @@ -167,6 +190,40 @@ class GuiBuildManuscript(QDialog): return + def _openSettingsDialog(self, build: BuildSettings): + """Open a new build settings dialog. + """ + dlgSettings = GuiBuildSettings(self.mainGui, build) + dlgSettings.setModal(False) + dlgSettings.show() + dlgSettings.raise_() + qApp.processEvents() + dlgSettings.loadContent() + dlgSettings.newSettingsReady.connect(self._processNewSettings) + return + + def _updateBuildsList(self): + """Update the list of available builds. + """ + self.buildList.clear() + for key, name in self._builds.builds(): + bItem = QListWidgetItem() + bItem.setText(name) + bItem.setData(Qt.UserRole, key) + self.buildList.addItem(bItem) + self._buildMap[key] = bItem + return + + def _updateBuildItem(self, build: BuildSettings): + """Update the entry of a specific build item. + """ + bItem = self._buildMap.get(build.buildID, None) + if isinstance(bItem, QListWidgetItem): + bItem.setText(build.name) + else: # Propbably a new item + self._updateBuildsList() + return + # END Class GuiBuildManuscript diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index 27cfdb0f..f7d69287 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -59,7 +59,7 @@ class GuiBuildSettings(QDialog): OPT_CONTENT = 4 OPT_OUTPUT = 5 - newSettingsReady = pyqtSignal(dict) + newSettingsReady = pyqtSignal(BuildSettings) def __init__(self, mainGui: GuiMain, build: BuildSettings): super().__init__(parent=mainGui) @@ -194,7 +194,7 @@ class GuiBuildSettings(QDialog): role = self.dlgButtons.buttonRole(button) if role in (QDialogButtonBox.ApplyRole, QDialogButtonBox.AcceptRole): self._build.setName(self.editBuildName.text()) - self.newSettingsReady.emit(self._build.pack()) + self.newSettingsReady.emit(self._build) self._saveSettings() if role == QDialogButtonBox.AcceptRole: From 25924236375b35b077ee7ec5e514983746b7a15c Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Mon, 22 May 2023 21:58:05 +0200 Subject: [PATCH 29/74] Update build dialog and make doc build class compatible with build settings --- novelwriter/core/buildsettings.py | 39 +++++++-- novelwriter/core/docbuild.py | 127 ++++++++++++++++------------- novelwriter/tools/manusbuild.py | 56 ++++++++++--- novelwriter/tools/manussettings.py | 20 ++--- 4 files changed, 161 insertions(+), 81 deletions(-) diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index 5ecc6f32..91f97ebf 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -63,12 +63,14 @@ SETTINGS_TEMPLATE = { "text.includeComments": (bool, False), "text.includeKeywords": (bool, False), "text.includeBody": (bool, True), + "text.addNoteHeadings": (bool, True), "format.buildLang": (str, "en_GB"), "format.textFont": (str, ""), "format.textSize": (int, 12), "format.lineHeight": (float, 1.15, 0.75, 3.0), "format.justifyText": (bool, False), "format.stripUnicode": (bool, False), + "format.replaceTabs": (bool, False), "odt.addColours": (bool, True), "html.addStyles": (bool, False), } @@ -93,6 +95,7 @@ SETTINGS_LABELS = { "text.includeComments": QT_TRANSLATE_NOOP("Builds", "Comments"), "text.includeKeywords": QT_TRANSLATE_NOOP("Builds", "Keywords"), "text.includeBody": QT_TRANSLATE_NOOP("Builds", "Body Text"), + "text.addNoteHeadings": QT_TRANSLATE_NOOP("Builds", "Add Titles for Notes"), "format": QT_TRANSLATE_NOOP("Builds", "Text Format"), "format.buildLang": QT_TRANSLATE_NOOP("Builds", "Build Language"), @@ -101,6 +104,7 @@ SETTINGS_LABELS = { "format.lineHeight": QT_TRANSLATE_NOOP("Builds", "Line Height"), "format.justifyText": QT_TRANSLATE_NOOP("Builds", "Justify Text Margins"), "format.stripUnicode": QT_TRANSLATE_NOOP("Builds", "Replace Unicode Characters"), + "format.replaceTabs": QT_TRANSLATE_NOOP("Builds", "Replace Tabs with Spaces"), "odt": QT_TRANSLATE_NOOP("Builds", "Open Document"), "odt.addColours": QT_TRANSLATE_NOOP("Builds", "Add Highlight Colours"), @@ -159,10 +163,33 @@ class BuildSettings: """ return SETTINGS_LABELS.get(key, "ERROR") - def getValue(self, key: str) -> str | int | bool | float: - """Get the value for a specific item, or return the default. + def getStr(self, key: str) -> str: + """Type safe value access for strings. """ - return self._settings.get(key, SETTINGS_TEMPLATE.get(key, (None, None)[1])) + value = self._settings.get(key, SETTINGS_TEMPLATE.get(key, (None, None)[1])) + return str(value) + + def getBool(self, key: str) -> bool: + """Type safe value access for bools. + """ + value = self._settings.get(key, SETTINGS_TEMPLATE.get(key, (None, None)[1])) + return bool(value) + + def getInt(self, key: str) -> int: + """Type safe value access for integers. + """ + value = self._settings.get(key, SETTINGS_TEMPLATE.get(key, (None, None)[1])) + if isinstance(value, int): + return value + return 0 + + def getFloat(self, key: str) -> float: + """Type safe value access for float. + """ + value = self._settings.get(key, SETTINGS_TEMPLATE.get(key, (None, None)[1])) + if isinstance(value, float): + return value + return 0.0 ## # Setters @@ -258,9 +285,9 @@ class BuildSettings: if not isinstance(project, NWProject): return result - incNovel = bool(self.getValue("filter.includeNovel")) - incNotes = bool(self.getValue("filter.includeNotes")) - incInactive = bool(self.getValue("filter.includeInactive")) + incNovel = bool(self.getBool("filter.includeNovel")) + incNotes = bool(self.getBool("filter.includeNotes")) + incInactive = bool(self.getBool("filter.includeInactive")) for item in project.tree: tHandle = item.itemHandle diff --git a/novelwriter/core/docbuild.py b/novelwriter/core/docbuild.py index 64f4326a..63020838 100644 --- a/novelwriter/core/docbuild.py +++ b/novelwriter/core/docbuild.py @@ -22,27 +22,33 @@ 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 . """ +from __future__ import annotations import logging +from pathlib import Path +from typing import Iterable -from PyQt5.QtGui import QFont, QFontInfo +from PyQt5.QtGui import QFont, QFontDatabase, QFontInfo from novelwriter import CONFIG +from novelwriter.core.tokenizer import Tokenizer from novelwriter.error import formatException from novelwriter.core.tomd import ToMarkdown from novelwriter.core.toodt import ToOdt from novelwriter.core.tohtml import ToHtml +from novelwriter.core.project import NWProject +from novelwriter.core.buildsettings import BuildSettings logger = logging.getLogger(__name__) class NWBuildDocument: - def __init__(self, project): + def __init__(self, project: NWProject, build: BuildSettings): self._project = project - self._build = {} - self._documents = [] + self._build = build + self._queue = [] self._error = None return @@ -52,41 +58,43 @@ class NWBuildDocument: ## @property - def error(self): + def error(self) -> str | None: return self._error @property - def buildLength(self): - return len(self._documents) - - ## - # Setters - ## - - def setBuildConfig(self, config): - """Set the build config dictionary. - """ - self._build = config - return - - def addDocument(self, tHandle): - """Add a document to the build queue. - """ - self._documents.append(tHandle) - return + def buildLength(self) -> int: + return len(self._queue) ## # Methods ## - def iterBuildOpenDocument(self, savePath, isFlat): + def addDocument(self, tHandle: str): + """Add a document to the build queue manually. + """ + self._queue.append(tHandle) + return + + def queueAll(self): + """Queue all document as defined by the build setup. + """ + filtered = self._build.buildItemFilter(self._project) + noteTitles = self._build.getValue("text.addNoteHeadings") + for item in self._project.tree: + if filtered.get(item.itemHandle, False): + self._queue.append(item.itemHandle) + elif item.isRootType() and noteTitles: + self._queue.append(item.itemHandle) + return + + def iterBuildOpenDocument(self, savePath: Path, isFlat: bool) -> Iterable[tuple[int, bool]]: """Build an Open Document file. """ makeOdt = ToOdt(self._project, isFlat=isFlat) self._setupBuild(makeOdt) makeOdt.initDocument() - for i, tHandle in enumerate(self._documents): + for i, tHandle in enumerate(self._queue): yield i, self._doBuild(makeOdt, tHandle) makeOdt.closeDocument() @@ -102,16 +110,16 @@ class NWBuildDocument: return - def iterBuildHTML(self, savePath): + def iterBuildHTML(self, savePath: Path) -> Iterable[tuple[int, bool]]: """Build an HTML file. """ makeHtml = ToHtml(self._project) self._setupBuild(makeHtml) - if self._build.get("process.replaceTabs", False): + if self._build.getValue("format.replaceTabs"): makeHtml.replaceTabs() - for i, tHandle in enumerate(self._documents): + for i, tHandle in enumerate(self._queue): yield i, self._doBuild(makeHtml, tHandle) self._error = None @@ -122,7 +130,7 @@ class NWBuildDocument: return - def iterBuildMarkdown(self, savePath, extendedMd): + def iterBuildMarkdown(self, savePath: Path, extendedMd: bool) -> Iterable[tuple[int, bool]]: """Build a Markdown file. """ makeMd = ToMarkdown(self._project) @@ -133,10 +141,10 @@ class NWBuildDocument: else: makeMd.setStandardMarkdown() - if self._build.get("process.replaceTabs", False): + if self._build.getValue("format.replaceTabs"): makeMd.replaceTabs(nSpaces=4, spaceChar=" ") - for i, tHandle in enumerate(self._documents): + for i, tHandle in enumerate(self._queue): yield i, self._doBuild(makeMd, tHandle) self._error = None @@ -151,34 +159,44 @@ class NWBuildDocument: # Internal Functions ## - def _setupBuild(self, bldObj): + def _setupBuild(self, bldObj: Tokenizer): """Configure the build object. """ # Get Settings - fmtTitle = self._build.get("format.fmtTitle", "%title%") - fmtChapter = self._build.get("format.fmtChapter", "%title%") - fmtUnnumbered = self._build.get("format.fmtUnnumbered", "%title%") - fmtScene = self._build.get("format.fmtScene", "%title%") - fmtSection = self._build.get("format.fmtSection", "%title%") - buildLang = self._build.get("format.buildLang", "en_GB") - hideScene = self._build.get("format.hideScene", False) - hideSection = self._build.get("format.hideSection", False) - textFont = self._build.get("format.textFont", CONFIG.textFont) - textSize = self._build.get("format.textSize", CONFIG.textSize) - lineHeight = self._build.get("format.lineHeight", 1.15) - justifyText = self._build.get("format.justifyText", False) - noStyling = self._build.get("format.noStyling", False) - replaceUCode = self._build.get("format.replaceUCode", False) - incSynopsis = self._build.get("filter.includeSynopsis", False) - incComments = self._build.get("filter.includeComments", False) - incKeywords = self._build.get("filter.includeKeywords", False) - includeBody = self._build.get("filter.includeBody", True) + fmtTitle = self._build.getStr("headings.fmtTitle") + fmtChapter = self._build.getStr("headings.fmtChapter") + fmtUnnumbered = self._build.getStr("headings.fmtUnnumbered") + fmtScene = self._build.getStr("headings.fmtScene") + fmtSection = self._build.getStr("headings.fmtSection") + hideScene = self._build.getBool("headings.hideScene") + hideSection = self._build.getBool("headings.hideSection") + + incSynopsis = self._build.getBool("text.includeSynopsis") + incComments = self._build.getBool("text.includeComments") + incKeywords = self._build.getBool("text.includeKeywords") + includeBody = self._build.getBool("text.includeBody") + + buildLang = self._build.getStr("format.buildLang") + textFont = self._build.getStr("format.textFont") + textSize = self._build.getInt("format.textSize") + lineHeight = self._build.getFloat("format.lineHeight") + justifyText = self._build.getBool("format.justifyText") + replaceUCode = self._build.getBool("format.stripUnicode") + + odtAddColours = self._build.getBool("odt.addColours") + htmlAddStyles = self._build.getBool("html.addStyles") # The language lookup dict is reloaded if needed self._project.setProjectLang(buildLang) # Get font information - fontInfo = QFontInfo(QFont(textFont, textSize)) + if not textFont: + textFont = str(CONFIG.textFont) + if not textFont: + textFont = QFontDatabase.systemFont(QFontDatabase.GeneralFont).family() + + bldFont = QFont(family=textFont, pointSize=textSize) + fontInfo = QFontInfo(bldFont) textFixed = fontInfo.fixedPitch() bldObj.setTitleFormat(fmtTitle) @@ -197,16 +215,16 @@ class NWBuildDocument: bldObj.setBodyText(includeBody) if isinstance(bldObj, ToHtml): - bldObj.setStyles(not noStyling) + bldObj.setStyles(htmlAddStyles) bldObj.setReplaceUnicode(replaceUCode) if isinstance(bldObj, ToOdt): - bldObj.setColourHeaders(not noStyling) + bldObj.setColourHeaders(odtAddColours) bldObj.setLanguage(buildLang) return - def _doBuild(self, bldObj, tHandle): + def _doBuild(self, bldObj: Tokenizer, tHandle: str) -> bool: """Build a single document and add it to the build object. """ self._error = None @@ -226,7 +244,6 @@ class NWBuildDocument: bldObj.tokenizeText() bldObj.doHeaders() bldObj.doConvert() - bldObj.doPostProcessing() else: logger.info(f"Build: Skipping '{tHandle}'") diff --git a/novelwriter/tools/manusbuild.py b/novelwriter/tools/manusbuild.py index cdebd0ea..986f1d26 100644 --- a/novelwriter/tools/manusbuild.py +++ b/novelwriter/tools/manusbuild.py @@ -30,8 +30,8 @@ from typing import TYPE_CHECKING from PyQt5.QtCore import Qt, pyqtSlot from PyQt5.QtWidgets import ( - QDialog, QHBoxLayout, QListWidget, QListWidgetItem, QPushButton, QSplitter, - QTextBrowser, QVBoxLayout, QWidget, qApp + QDialog, QHBoxLayout, QListWidget, QListWidgetItem, QMenu, QProgressBar, + QPushButton, QSplitter, QTextBrowser, QVBoxLayout, QWidget, qApp ) from novelwriter import CONFIG @@ -69,8 +69,8 @@ class GuiBuildManuscript(QDialog): CONFIG.pxInt(pOptions.getInt("GuiBuildManuscript", "winHeight", hWin)) ) - # Controls - # ======== + # Build Controls + # ============== self.buildList = QListWidget() @@ -80,21 +80,57 @@ class GuiBuildManuscript(QDialog): self.btnEdit = QPushButton(self.tr("Edit")) self.btnEdit.clicked.connect(self._editSelectedBuild) - self.btnDel = QPushButton(self.tr("Delete")) + self.btnDelete = QPushButton(self.tr("Delete")) - self.buttonBox = QHBoxLayout() - self.buttonBox.addWidget(self.btnNew) - self.buttonBox.addWidget(self.btnEdit) - self.buttonBox.addWidget(self.btnDel) + # Process Controls + # ================ + self.buildProgress = QProgressBar() + self.btnPreview = QPushButton(self.tr("Build Preview")) self.manPreview = GuiManuscriptPreview(self) + self.menuPrint = QMenu(self) + self.aPrintSend = self.menuPrint.addAction(self.tr("Print Preview")) + self.aPrintFile = self.menuPrint.addAction(self.tr("Print to PDF")) + + self.menuSave = QMenu(self) + self.aSaveODT = self.menuSave.addAction(self.tr("Open Document (.odt)")) + self.aSaveFODT = self.menuSave.addAction(self.tr("Flat Open Document (.fodt)")) + self.aSaveHTM = self.menuSave.addAction(self.tr("novelWriter HTML (.htm)")) + self.aSaveNWD = self.menuSave.addAction(self.tr("novelWriter Markdown (.nwd)")) + self.aSaveMD = self.menuSave.addAction(self.tr("Standard Markdown (.md)")) + self.aSaveGH = self.menuSave.addAction(self.tr("GitHub Markdown (.md)")) + self.aSaveJsonH = self.menuSave.addAction(self.tr("JSON + novelWriter HTML (.json)")) + self.aSaveJsonM = self.menuSave.addAction(self.tr("JSON + novelWriter Markdown (.json)")) + + self.btnPrint = QPushButton(self.tr("Print")) + self.btnPrint.setMenu(self.menuPrint) + + self.btnSave = QPushButton(self.tr("Save As")) + self.btnSave.setMenu(self.menuSave) + + self.btnClose = QPushButton(self.tr("Close")) + # Assemble GUI # ============ + self.buildBox = QHBoxLayout() + self.buildBox.addWidget(self.btnNew) + self.buildBox.addWidget(self.btnEdit) + self.buildBox.addWidget(self.btnDelete) + + self.processBox = QHBoxLayout() + self.processBox.addWidget(self.btnSave) + self.processBox.addWidget(self.btnPrint) + self.processBox.addWidget(self.btnClose) + self.controlBox = QVBoxLayout() self.controlBox.addWidget(self.buildList) - self.controlBox.addLayout(self.buttonBox) + self.controlBox.addLayout(self.buildBox) + self.controlBox.addWidget(self.buildProgress) + self.controlBox.addWidget(self.btnPreview) + self.controlBox.addLayout(self.processBox) + self.controlBox.setContentsMargins(0, 0, 0, 0) self.optsWidget = QWidget() self.optsWidget.setLayout(self.controlBox) diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index f7d69287..a5200d33 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -457,19 +457,19 @@ class GuiBuildFilterTab(QWidget): self.mainTheme.getIcon("proj_scene"), self._build.getLabel("filter.includeNovel"), "doc:filter.includeNovel", - default=self._build.getValue("filter.includeNovel") or False + default=self._build.getBool("filter.includeNovel") ) self.filterOpt.addItem( self.mainTheme.getIcon("proj_note"), self._build.getLabel("filter.includeNotes"), "doc:filter.includeNotes", - default=self._build.getValue("filter.includeNotes") or False + default=self._build.getBool("filter.includeNotes") ) self.filterOpt.addItem( self.mainTheme.getIcon("unchecked"), self._build.getLabel("filter.includeInactive"), "doc:filter.includeInactive", - default=self._build.getValue("filter.includeInactive") or False + default=self._build.getBool("filter.includeInactive") ) self.filterOpt.addSeparator() @@ -708,13 +708,13 @@ class GuiBuildHeadingsTab(QWidget): def loadContent(self): """Populate the widgets. """ - self.fmtTitle.setText(str(self._build.getValue("headings.fmtTitle"))) - self.fmtChapter.setText(str(self._build.getValue("headings.fmtChapter"))) - self.fmtUnnumbered.setText(str(self._build.getValue("headings.fmtUnnumbered"))) - self.fmtScene.setText(str(self._build.getValue("headings.fmtScene"))) - self.fmtSection.setText(str(self._build.getValue("headings.fmtSection"))) - self.swtScene.setChecked(bool(self._build.getValue("headings.hideScene"))) - self.swtSection.setChecked(bool(self._build.getValue("headings.hideSection"))) + self.fmtTitle.setText(self._build.getStr("headings.fmtTitle")) + self.fmtChapter.setText(self._build.getStr("headings.fmtChapter")) + self.fmtUnnumbered.setText(self._build.getStr("headings.fmtUnnumbered")) + self.fmtScene.setText(self._build.getStr("headings.fmtScene")) + self.fmtSection.setText(self._build.getStr("headings.fmtSection")) + self.swtScene.setChecked(self._build.getBool("headings.hideScene")) + self.swtSection.setChecked(self._build.getBool("headings.hideSection")) return ## From 06b46bb0d3e15cc6eae1c636b749e57dc5c8e069 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 23 May 2023 16:39:05 +0200 Subject: [PATCH 30/74] Update config layout extension class --- novelwriter/dialogs/preferences.py | 38 +++--- novelwriter/extensions/configlayout.py | 167 ++++++++++++++++--------- 2 files changed, 125 insertions(+), 80 deletions(-) diff --git a/novelwriter/dialogs/preferences.py b/novelwriter/dialogs/preferences.py index b6b252de..21fd101f 100644 --- a/novelwriter/dialogs/preferences.py +++ b/novelwriter/dialogs/preferences.py @@ -230,7 +230,7 @@ class GuiPreferencesGeneral(QWidget): self.tr("Font family"), self.guiFont, self.tr("Requires restart to take effect."), - theButton=self.fontButton + button=self.fontButton ) # Font Size @@ -243,7 +243,7 @@ class GuiPreferencesGeneral(QWidget): self.tr("Font size"), self.guiFontSize, self.tr("Requires restart to take effect."), - theUnit=self.tr("pt") + unit=self.tr("pt") ) # GUI Settings @@ -360,7 +360,7 @@ class GuiPreferencesProjects(QWidget): self.tr("Save document interval"), self.autoSaveDoc, self.tr("How often the document is automatically saved."), - theUnit=self.tr("seconds") + unit=self.tr("seconds") ) # Project Save Timer @@ -373,7 +373,7 @@ class GuiPreferencesProjects(QWidget): self.tr("Save project interval"), self.autoSaveProj, self.tr("How often the project is automatically saved."), - theUnit=self.tr("seconds") + unit=self.tr("seconds") ) # Project Backup @@ -435,7 +435,7 @@ class GuiPreferencesProjects(QWidget): self.tr("Editor inactive time before pausing timer"), self.userIdleTime, self.tr("User activity includes typing and changing the content."), - theUnit=self.tr("minutes") + unit=self.tr("minutes") ) return @@ -517,7 +517,7 @@ class GuiPreferencesDocuments(QWidget): self.tr("Font family"), self.textFont, self.tr("Applies to both document editor and viewer."), - theButton=self.fontButton + button=self.fontButton ) # Font Size @@ -530,7 +530,7 @@ class GuiPreferencesDocuments(QWidget): self.tr("Font size"), self.textSize, self.tr("Applies to both document editor and viewer."), - theUnit=self.tr("pt") + unit=self.tr("pt") ) # Text Flow @@ -547,7 +547,7 @@ class GuiPreferencesDocuments(QWidget): self.tr("Maximum text width in \"Normal Mode\""), self.textWidth, self.tr("Set to 0 to disable this feature."), - theUnit=self.tr("px") + unit=self.tr("px") ) # Max Text Width in Focus Mode @@ -560,7 +560,7 @@ class GuiPreferencesDocuments(QWidget): self.tr("Maximum text width in \"Focus Mode\""), self.focusWidth, self.tr("The maximum width cannot be disabled."), - theUnit=self.tr("px") + unit=self.tr("px") ) # Focus Mode Footer @@ -591,7 +591,7 @@ class GuiPreferencesDocuments(QWidget): self.tr("Minimum text margin"), self.textMargin, self.tr("Applies to both document editor and viewer."), - theUnit=self.tr("px") + unit=self.tr("px") ) # Tab Width @@ -604,7 +604,7 @@ class GuiPreferencesDocuments(QWidget): self.tr("Tab width"), self.tabWidth, self.tr("The width of a tab key press in the editor and viewer."), - theUnit=self.tr("px") + unit=self.tr("px") ) return @@ -703,7 +703,7 @@ class GuiPreferencesEditor(QWidget): self.tr("Big document limit"), self.bigDocLimit, self.tr("Full spell checking is disabled above this limit."), - theUnit=self.tr("kB") + unit=self.tr("kB") ) # Word Count @@ -720,7 +720,7 @@ class GuiPreferencesEditor(QWidget): self.mainForm.addRow( self.tr("Word count interval"), self.wordCountTimer, - theUnit=self.tr("seconds") + unit=self.tr("seconds") ) # Include Notes in Word Count @@ -765,7 +765,7 @@ class GuiPreferencesEditor(QWidget): self.tr("Scroll past end of the document"), self.scrollPastEnd, self.tr("Set to 0 to disable this feature."), - theUnit=self.tr("lines") + unit=self.tr("lines") ) # Typewriter Scrolling @@ -787,7 +787,7 @@ class GuiPreferencesEditor(QWidget): self.tr("Minimum position for Typewriter scrolling"), self.autoScrollPos, self.tr("Percentage of the editor height from the top."), - theUnit="%" + unit="%" ) return @@ -1112,7 +1112,7 @@ class GuiPreferencesQuotes(QWidget): self.tr("Single quote open style"), self.quoteSym["SO"], self.tr("The symbol to use for a leading single quote."), - theButton=self.btnSingleStyleO + button=self.btnSingleStyleO ) self.quoteSym["SC"] = QLineEdit() @@ -1128,7 +1128,7 @@ class GuiPreferencesQuotes(QWidget): self.tr("Single quote close style"), self.quoteSym["SC"], self.tr("The symbol to use for a trailing single quote."), - theButton=self.btnSingleStyleC + button=self.btnSingleStyleC ) # Double Quote Style @@ -1145,7 +1145,7 @@ class GuiPreferencesQuotes(QWidget): self.tr("Double quote open style"), self.quoteSym["DO"], self.tr("The symbol to use for a leading double quote."), - theButton=self.btnDoubleStyleO + button=self.btnDoubleStyleO ) self.quoteSym["DC"] = QLineEdit() @@ -1161,7 +1161,7 @@ class GuiPreferencesQuotes(QWidget): self.tr("Double quote close style"), self.quoteSym["DC"], self.tr("The symbol to use for a trailing double quote."), - theButton=self.btnDoubleStyleC + button=self.btnDoubleStyleC ) return diff --git a/novelwriter/extensions/configlayout.py b/novelwriter/extensions/configlayout.py index f50ac2cf..cada2efa 100644 --- a/novelwriter/extensions/configlayout.py +++ b/novelwriter/extensions/configlayout.py @@ -22,16 +22,19 @@ 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 . """ +from __future__ import annotations from PyQt5.QtGui import QColor, QPalette from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( - QGridLayout, QHBoxLayout, QLabel, QLineEdit, QSizePolicy, QVBoxLayout, - QWidget + QAbstractButton, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QSizePolicy, + QVBoxLayout, QWidget ) from novelwriter import CONFIG +FONT_SCALE = 0.9 + class NConfigLayout(QGridLayout): @@ -40,8 +43,7 @@ class NConfigLayout(QGridLayout): self._nextRow = 0 self._helpCol = QColor(0, 0, 0) - self._fontScale = 0.9 - + self._fontScale = FONT_SCALE self._itemMap = {} wSp = CONFIG.pxInt(8) @@ -55,81 +57,65 @@ class NConfigLayout(QGridLayout): # Getters and Setters ## - def setHelpTextStyle(self, helpCol, fontScale=0.9): + def setHelpTextStyle(self, color: QColor | list | tuple, fontScale: float = FONT_SCALE): """Set the text color for the help text. """ - if isinstance(helpCol, QColor): - self._helpCol = helpCol + if isinstance(color, QColor): + self._helpCol = color else: - self._helpCol = QColor(*helpCol) + self._helpCol = QColor(*color) self._fontScale = fontScale return - def setHelpText(self, intRow, theText): + def setHelpText(self, row: int, text: str): """Set the text for the help label. """ - if intRow in self._itemMap: - self._itemMap[intRow]["help"].setText(theText) + if row in self._itemMap: + qHelp = self._itemMap[row][1] + if isinstance(qHelp, NHelpLabel): + qHelp.setText(text) return - def setLabelText(self, intRow, theText): + def setLabelText(self, row: int, text: str): """Set the text for the main label. """ - if intRow in self._itemMap: - self._itemMap[intRow]["label"].setText(theText) + if row in self._itemMap: + self._itemMap[row](0).setText(text) return ## # Class Methods ## - def addGroupLabel(self, theLabel): + def addGroupLabel(self, label: str): """Adds a text label to separate groups of settings. """ - if isinstance(theLabel, QLabel): - qLabel = theLabel - elif isinstance(theLabel, str): - qLabel = QLabel("%s" % theLabel) - else: - qLabel = None - raise ValueError("theLabel must be a QLabel") - hM = CONFIG.pxInt(4) + qLabel = QLabel("%s" % label) 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): + def addRow( + self, label: str, widget: QWidget, helpText: str | None = None, + unit: str | None = None, button: QWidget | None = None + ) -> int: """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 + if isinstance(widget, QWidget): + qWidget = widget else: qWidget = None - raise ValueError("theWidget must be a QWidget") + raise ValueError("The widget must be a QWidget") wSp = CONFIG.pxInt(8) + qLabel = QLabel(label) qLabel.setIndent(wSp) + + qHelp = None if helpText is not None: qHelp = NHelpLabel(str(helpText), self._helpCol, self._fontScale) qHelp.setIndent(wSp) @@ -140,30 +126,29 @@ class NConfigLayout(QGridLayout): 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: + if isinstance(unit, str): controlBox = QHBoxLayout() controlBox.addWidget(qWidget, 0, Qt.AlignVCenter) - controlBox.addWidget(QLabel(theUnit), 0, Qt.AlignVCenter) + controlBox.addWidget(QLabel(unit), 0, Qt.AlignVCenter) controlBox.setSpacing(wSp) self.addLayout(controlBox, self._nextRow, 1, 1, 1, Qt.AlignRight | Qt.AlignTop) - elif theButton is not None: + elif isinstance(button, QAbstractButton): controlBox = QHBoxLayout() controlBox.addWidget(qWidget, 0, Qt.AlignVCenter) - controlBox.addWidget(theButton, 0, Qt.AlignVCenter) + controlBox.addWidget(button, 0, Qt.AlignVCenter) controlBox.setSpacing(wSp) self.addLayout(controlBox, self._nextRow, 1, 1, 1, Qt.AlignRight | Qt.AlignTop) else: - if isinstance(theWidget, QLineEdit): + if isinstance(widget, QLineEdit): qLayout = QHBoxLayout() - qLayout.addWidget(theWidget) + qLayout.addWidget(widget) 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) @@ -173,11 +158,7 @@ class NConfigLayout(QGridLayout): self.setRowStretch(self._nextRow, 0) self.setRowStretch(self._nextRow+1, 1) - thisEntry["label"] = qLabel - thisEntry["widget"] = qWidget - - self._itemMap[self._nextRow] = thisEntry - + self._itemMap[self._nextRow] = (qLabel, qHelp, qWidget) self._nextRow += 1 return self._nextRow - 1 @@ -185,15 +166,79 @@ class NConfigLayout(QGridLayout): # END Class NConfigLayout +class NSimpleLayout(QGridLayout): + """Similar to NConfigLayout, but only has a label + widget two + column layout. + """ + + def __init__(self): + super().__init__() + self._nextRow = 0 + + wSp = CONFIG.pxInt(8) + self.setHorizontalSpacing(wSp) + self.setVerticalSpacing(wSp) + self.setColumnStretch(0, 1) + + return + + ## + # Methods + ## + + def addGroupLabel(self, label: str): + """Adds a text label to separate groups of settings. + """ + hM = CONFIG.pxInt(4) + qLabel = QLabel("%s" % label) + 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, label: str, widget: QWidget): + """Add a label and a widget as a new row of the grid. + """ + if isinstance(widget, QWidget): + qWidget = widget + else: + qWidget = None + raise ValueError("The widget must be a QWidget") + + wSp = CONFIG.pxInt(8) + qLabel = QLabel(label) + qLabel.setIndent(wSp) + self.addWidget(qLabel, self._nextRow, 0, 1, 1, Qt.AlignLeft | Qt.AlignTop) + + if isinstance(widget, QLineEdit): + qLayout = QHBoxLayout() + qLayout.addWidget(widget) + 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) + self._nextRow += 1 + + return + +# END Class NSimpleLayout + + class NHelpLabel(QLabel): - def __init__(self, theText, textCol, fontSize=0.9): - super().__init__(theText) + def __init__(self, text: str, color: QColor | list | tuple, fontSize: float = FONT_SCALE): + super().__init__(text) - if isinstance(textCol, QColor): - qCol = textCol + if isinstance(color, QColor): + qCol = color else: - qCol = QColor(*textCol) + qCol = QColor(*color) lblCol = self.palette() lblCol.setColor(QPalette.WindowText, qCol) From c302162d1f55251cf39f0692fe020605ce2b8d57 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 23 May 2023 16:39:43 +0200 Subject: [PATCH 31/74] Add build settings Content and Format tabs --- novelwriter/constants.py | 4 + novelwriter/core/buildsettings.py | 46 ++-- novelwriter/core/docbuild.py | 9 +- novelwriter/tools/manussettings.py | 329 +++++++++++++++++++++++------ 4 files changed, 294 insertions(+), 94 deletions(-) diff --git a/novelwriter/constants.py b/novelwriter/constants.py index da807c6d..717f3cdb 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -23,6 +23,7 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ +from PyQt5.QtGui import QFontDatabase from PyQt5.QtCore import QCoreApplication, QT_TRANSLATE_NOOP from novelwriter.enum import nwItemClass, nwItemLayout, nwOutline @@ -53,6 +54,9 @@ class nwConst: URL_HELP = "https://github.com/vkbo/novelWriter/discussions" URL_RELEASE = "https://github.com/vkbo/novelWriter/releases/latest" + # System Values + SYSTEM_FONT = QFontDatabase.systemFont(QFontDatabase.GeneralFont).family() + # END Class nwConst diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index 91f97ebf..5704fd75 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -36,7 +36,7 @@ from pathlib import Path from PyQt5.QtCore import QT_TRANSLATE_NOOP from novelwriter.common import checkUuid, isHandle, jsonEncode -from novelwriter.constants import nwFiles, nwHeadingFormats +from novelwriter.constants import nwConst, nwFiles, nwHeadingFormats from novelwriter.core.item import NWItem from novelwriter.core.project import NWProject from novelwriter.error import logException @@ -62,10 +62,10 @@ SETTINGS_TEMPLATE = { "text.includeSynopsis": (bool, False), "text.includeComments": (bool, False), "text.includeKeywords": (bool, False), - "text.includeBody": (bool, True), + "text.includeBodyText": (bool, True), "text.addNoteHeadings": (bool, True), "format.buildLang": (str, "en_GB"), - "format.textFont": (str, ""), + "format.textFont": (str, nwConst.SYSTEM_FONT), "format.textSize": (int, 12), "format.lineHeight": (float, 1.15, 0.75, 3.0), "format.justifyText": (bool, False), @@ -82,26 +82,28 @@ SETTINGS_LABELS = { "filter.includeInactive": QT_TRANSLATE_NOOP("Builds", "Inactive Documents"), "headings": QT_TRANSLATE_NOOP("Builds", "Headings"), - "headings.fmtTitle": QT_TRANSLATE_NOOP("Builds", "Title Heading"), - "headings.fmtChapter": QT_TRANSLATE_NOOP("Builds", "Chapter Heading"), - "headings.fmtUnnumbered": QT_TRANSLATE_NOOP("Builds", "Unnumbered Heading"), - "headings.fmtScene": QT_TRANSLATE_NOOP("Builds", "Scene Heading"), - "headings.fmtSection": QT_TRANSLATE_NOOP("Builds", "Section Heading"), - "headings.hideScene": QT_TRANSLATE_NOOP("Builds", "Hide Scene"), - "headings.hideSection": QT_TRANSLATE_NOOP("Builds", "Hide Section"), + "headings.fmtTitle": QT_TRANSLATE_NOOP("Builds", "Title Headings"), + "headings.fmtChapter": QT_TRANSLATE_NOOP("Builds", "Chapter Headings"), + "headings.fmtUnnumbered": QT_TRANSLATE_NOOP("Builds", "Unnumbered Headings"), + "headings.fmtScene": QT_TRANSLATE_NOOP("Builds", "Scene Headings"), + "headings.fmtSection": QT_TRANSLATE_NOOP("Builds", "Section Headings"), + "headings.hideScene": QT_TRANSLATE_NOOP("Builds", "Hide Scene Headings"), + "headings.hideSection": QT_TRANSLATE_NOOP("Builds", "Hide Section Headings"), - "text": QT_TRANSLATE_NOOP("Builds", "Text Content"), - "text.includeSynopsis": QT_TRANSLATE_NOOP("Builds", "Synopsis"), - "text.includeComments": QT_TRANSLATE_NOOP("Builds", "Comments"), - "text.includeKeywords": QT_TRANSLATE_NOOP("Builds", "Keywords"), - "text.includeBody": QT_TRANSLATE_NOOP("Builds", "Body Text"), + "text.grpContent": QT_TRANSLATE_NOOP("Builds", "Text Content"), + "text.includeSynopsis": QT_TRANSLATE_NOOP("Builds", "Include Synopsis"), + "text.includeComments": QT_TRANSLATE_NOOP("Builds", "Include Comments"), + "text.includeKeywords": QT_TRANSLATE_NOOP("Builds", "Include Keywords"), + "text.includeBodyText": QT_TRANSLATE_NOOP("Builds", "Include Body Text"), + "text.grpInsert": QT_TRANSLATE_NOOP("Builds", "Insert Content"), "text.addNoteHeadings": QT_TRANSLATE_NOOP("Builds", "Add Titles for Notes"), - "format": QT_TRANSLATE_NOOP("Builds", "Text Format"), - "format.buildLang": QT_TRANSLATE_NOOP("Builds", "Build Language"), + "format.grpFormat": QT_TRANSLATE_NOOP("Builds", "Text Format"), + "format.buildLang": QT_TRANSLATE_NOOP("Builds", "Document Language"), "format.textFont": QT_TRANSLATE_NOOP("Builds", "Font Family"), "format.textSize": QT_TRANSLATE_NOOP("Builds", "Font Size"), "format.lineHeight": QT_TRANSLATE_NOOP("Builds", "Line Height"), + "format.grpOptions": QT_TRANSLATE_NOOP("Builds", "Text Options"), "format.justifyText": QT_TRANSLATE_NOOP("Builds", "Justify Text Margins"), "format.stripUnicode": QT_TRANSLATE_NOOP("Builds", "Replace Unicode Characters"), "format.replaceTabs": QT_TRANSLATE_NOOP("Builds", "Replace Tabs with Spaces"), @@ -382,7 +384,7 @@ class BuildCollection: return def getBuild(self, buildID: str) -> BuildSettings | None: - """ + """Get a specific build settings object. """ if buildID not in self._builds: return None @@ -391,7 +393,7 @@ class BuildCollection: return build def setBuild(self, build: BuildSettings) -> bool: - """ + """Set build settings data in the collection. """ if not isinstance(build, BuildSettings): return False @@ -400,14 +402,14 @@ class BuildCollection: return True def builds(self) -> Iterable[tuple[str, str]]: - """ + """Iterate over all avaiable builds. """ for buildID in self._builds: yield buildID, self._builds[buildID].get("name", "") return def loadCollection(self) -> bool: - """ + """Load build collections file. """ buildsFile = self._project.storage.getMetaFile(nwFiles.BUILDS_FILE) if not isinstance(buildsFile, Path): @@ -440,7 +442,7 @@ class BuildCollection: return True def saveCollection(self) -> bool: - """ + """Save build collections file. """ buildsFile = self._project.storage.getMetaFile(nwFiles.BUILDS_FILE) if not isinstance(buildsFile, Path): diff --git a/novelwriter/core/docbuild.py b/novelwriter/core/docbuild.py index 63020838..d6a5b481 100644 --- a/novelwriter/core/docbuild.py +++ b/novelwriter/core/docbuild.py @@ -28,9 +28,10 @@ import logging from pathlib import Path from typing import Iterable -from PyQt5.QtGui import QFont, QFontDatabase, QFontInfo +from PyQt5.QtGui import QFont, QFontInfo from novelwriter import CONFIG +from novelwriter.constants import nwConst from novelwriter.core.tokenizer import Tokenizer from novelwriter.error import formatException from novelwriter.core.tomd import ToMarkdown @@ -174,7 +175,7 @@ class NWBuildDocument: incSynopsis = self._build.getBool("text.includeSynopsis") incComments = self._build.getBool("text.includeComments") incKeywords = self._build.getBool("text.includeKeywords") - includeBody = self._build.getBool("text.includeBody") + incBodyText = self._build.getBool("text.includeBodyText") buildLang = self._build.getStr("format.buildLang") textFont = self._build.getStr("format.textFont") @@ -193,7 +194,7 @@ class NWBuildDocument: if not textFont: textFont = str(CONFIG.textFont) if not textFont: - textFont = QFontDatabase.systemFont(QFontDatabase.GeneralFont).family() + textFont = nwConst.SYSTEM_FONT bldFont = QFont(family=textFont, pointSize=textSize) fontInfo = QFontInfo(bldFont) @@ -212,7 +213,7 @@ class NWBuildDocument: bldObj.setSynopsis(incSynopsis) bldObj.setComments(incComments) bldObj.setKeywords(incKeywords) - bldObj.setBodyText(includeBody) + bldObj.setBodyText(incBodyText) if isinstance(bldObj, ToHtml): bldObj.setStyles(htmlAddStyles) diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index a5200d33..70ab7b56 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -28,18 +28,22 @@ import logging from typing import TYPE_CHECKING -from PyQt5.QtGui import QColor, QIcon, QSyntaxHighlighter, QTextCharFormat, QTextDocument +from PyQt5.QtGui import ( + QColor, QFont, QIcon, QSyntaxHighlighter, QTextCharFormat, QTextDocument +) from PyQt5.QtCore import QEvent, QSize, Qt, pyqtSignal, pyqtSlot from PyQt5.QtWidgets import ( - QAbstractButton, QAbstractItemView, QDialog, QDialogButtonBox, QGridLayout, - QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMenu, QPlainTextEdit, QPushButton, QSplitter, + QAbstractButton, QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, + QDoubleSpinBox, QFontDialog, QGridLayout, QHBoxLayout, QHeaderView, QLabel, + QLineEdit, QMenu, QPlainTextEdit, QPushButton, QSpinBox, QSplitter, QStackedWidget, QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget ) from novelwriter import CONFIG -from novelwriter.constants import nwHeadingFormats +from novelwriter.constants import nwConst, nwHeadingFormats from novelwriter.core.buildsettings import BuildSettings, FilterMode +from novelwriter.extensions.configlayout import NConfigLayout, NSimpleLayout from novelwriter.extensions.switch import NSwitch from novelwriter.extensions.switchbox import NSwitchBox from novelwriter.extensions.pagedsidebar import NPagedSideBar @@ -55,8 +59,8 @@ class GuiBuildSettings(QDialog): OPT_FILTERS = 1 OPT_HEADINGS = 2 - OPT_FORMAT = 3 - OPT_CONTENT = 4 + OPT_CONTENT = 3 + OPT_FORMAT = 4 OPT_OUTPUT = 5 newSettingsReady = pyqtSignal(BuildSettings) @@ -98,8 +102,8 @@ class GuiBuildSettings(QDialog): self.optSideBar.addLabel(self.tr("Options")) self.optSideBar.addButton(self.tr("Filters"), self.OPT_FILTERS) self.optSideBar.addButton(self.tr("Headings"), self.OPT_HEADINGS) - self.optSideBar.addButton(self.tr("Format"), self.OPT_FORMAT) self.optSideBar.addButton(self.tr("Content"), self.OPT_CONTENT) + self.optSideBar.addButton(self.tr("Format"), self.OPT_FORMAT) self.optSideBar.addButton(self.tr("Output"), self.OPT_OUTPUT) self.optSideBar.buttonClicked.connect(self._stackPageSelected) @@ -165,6 +169,8 @@ class GuiBuildSettings(QDialog): self.editBuildName.setText(self._build.name) self.optTabSelect.loadContent() self.optTabHeadings.loadContent() + self.optTabContent.loadContent() + self.optTabFormat.loadContent() return ## @@ -179,10 +185,10 @@ class GuiBuildSettings(QDialog): self.toolStack.setCurrentWidget(self.optTabSelect) elif pageId == self.OPT_HEADINGS: self.toolStack.setCurrentWidget(self.optTabHeadings) - elif pageId == self.OPT_FORMAT: - self.toolStack.setCurrentWidget(self.optTabFormat) elif pageId == self.OPT_CONTENT: self.toolStack.setCurrentWidget(self.optTabContent) + elif pageId == self.OPT_FORMAT: + self.toolStack.setCurrentWidget(self.optTabFormat) elif pageId == self.OPT_OUTPUT: self.toolStack.setCurrentWidget(self.optTabOutput) return @@ -194,6 +200,9 @@ class GuiBuildSettings(QDialog): role = self.dlgButtons.buttonRole(button) if role in (QDialogButtonBox.ApplyRole, QDialogButtonBox.AcceptRole): self._build.setName(self.editBuildName.text()) + self.optTabHeadings.saveContent() + self.optTabContent.saveContent() + self.optTabFormat.saveContent() self.newSettingsReady.emit(self._build) self._saveSettings() @@ -553,7 +562,7 @@ class GuiBuildHeadingsTab(QWidget): self.formatBox.setHorizontalSpacing(vSp) # Title Heading - self.lblTitle = QLabel(BuildSettings.getLabel("headings.fmtTitle")) + self.lblTitle = QLabel(self._build.getLabel("headings.fmtTitle")) self.fmtTitle = QLineEdit("") self.fmtTitle.setEnabled(False) self.btnTitle = QToolButton() @@ -569,7 +578,7 @@ class GuiBuildHeadingsTab(QWidget): self.formatBox.addLayout(wrapTitle, 0, 1, Qt.AlignLeft) # Chapter Heading - self.lblChapter = QLabel(BuildSettings.getLabel("headings.fmtChapter")) + self.lblChapter = QLabel(self._build.getLabel("headings.fmtChapter")) self.fmtChapter = QLineEdit("") self.fmtChapter.setEnabled(False) self.btnChapter = QToolButton() @@ -585,7 +594,7 @@ class GuiBuildHeadingsTab(QWidget): self.formatBox.addLayout(wrapChapter, 1, 1, Qt.AlignLeft) # Unnumbered Chapter Heading - self.lblUnnumbered = QLabel(BuildSettings.getLabel("headings.fmtUnnumbered")) + self.lblUnnumbered = QLabel(self._build.getLabel("headings.fmtUnnumbered")) self.fmtUnnumbered = QLineEdit("") self.fmtUnnumbered.setEnabled(False) self.btnUnnumbered = QToolButton() @@ -601,14 +610,17 @@ class GuiBuildHeadingsTab(QWidget): self.formatBox.addLayout(wrapUnnumbered, 2, 1, Qt.AlignLeft) # Scene Heading - self.lblScene = QLabel(BuildSettings.getLabel("headings.fmtScene")) + sceneHideTip = self._build.getLabel("headings.hideScene") + self.lblScene = QLabel(self._build.getLabel("headings.fmtScene")) self.fmtScene = QLineEdit("") self.fmtScene.setEnabled(False) self.btnScene = QToolButton() self.btnScene.setIcon(self.mainTheme.getIcon("edit")) self.btnScene.clicked.connect(lambda: self._editHeading(self.EDIT_SCENE)) + self.hdeScene = QLabel(self.tr("Hide")) + self.hdeScene.setToolTip(sceneHideTip) self.swtScene = NSwitch(width=2*iPx, height=iPx) - self.swtScene.toggled.connect(self._hideScene) + self.swtScene.setToolTip(sceneHideTip) wrapScene = QHBoxLayout() wrapScene.addWidget(self.fmtScene) @@ -616,7 +628,7 @@ class GuiBuildHeadingsTab(QWidget): wrapScene.setSpacing(bSp) wrapSceneHide = QHBoxLayout() - wrapSceneHide.addWidget(QLabel(self.tr("Hide"))) + wrapSceneHide.addWidget(self.hdeScene) wrapSceneHide.addWidget(self.swtScene) wrapSceneHide.setSpacing(bSp) @@ -625,14 +637,17 @@ class GuiBuildHeadingsTab(QWidget): self.formatBox.addLayout(wrapSceneHide, 3, 2, Qt.AlignLeft) # Section Heading - self.lblSection = QLabel(BuildSettings.getLabel("headings.fmtSection")) + sectionHideTip = self._build.getLabel("headings.hideSection") + self.lblSection = QLabel(self._build.getLabel("headings.fmtSection")) self.fmtSection = QLineEdit("") self.fmtSection.setEnabled(False) self.btnSection = QToolButton() self.btnSection.setIcon(self.mainTheme.getIcon("edit")) self.btnSection.clicked.connect(lambda: self._editHeading(self.EDIT_SECTION)) + self.hdeSection = QLabel(self.tr("Hide")) + self.hdeSection.setToolTip(sectionHideTip) self.swtSection = NSwitch(width=2*iPx, height=iPx) - self.swtSection.toggled.connect(self._hideSection) + self.swtSection.setToolTip(sectionHideTip) wrapSection = QHBoxLayout() wrapSection.addWidget(self.fmtSection) @@ -640,7 +655,7 @@ class GuiBuildHeadingsTab(QWidget): wrapSection.setSpacing(bSp) wrapSectionHide = QHBoxLayout() - wrapSectionHide.addWidget(QLabel(self.tr("Hide"))) + wrapSectionHide.addWidget(self.hdeSection) wrapSectionHide.addWidget(self.swtSection) wrapSectionHide.setSpacing(bSp) @@ -717,6 +732,13 @@ class GuiBuildHeadingsTab(QWidget): self.swtSection.setChecked(self._build.getBool("headings.hideSection")) return + def saveContent(self): + """Save choices back into build object. + """ + self._build.setValue("headings.hideScene", self.swtScene.isChecked()) + self._build.setValue("headings.hideSection", self.swtSection.isChecked()) + return + ## # Internal Functions ## @@ -793,59 +815,9 @@ class GuiBuildHeadingsTab(QWidget): return - @pyqtSlot(bool) - def _hideScene(self, status): - """Hide scene heading - """ - self._build.setValue("headings.hideScene", status) - return - - @pyqtSlot(bool) - def _hideSection(self, status): - """Hide section heading - """ - self._build.setValue("headings.hideSection", status) - return - # END Class GuiBuildHeadingsTab -class GuiBuildFormatTab(QWidget): - - def __init__(self, buildMain: GuiBuildSettings, build: BuildSettings): - super().__init__(parent=buildMain) - - self._build = build - - return - -# END Class GuiBuildFormatTab - - -class GuiBuildContentTab(QWidget): - - def __init__(self, buildMain: GuiBuildSettings, build: BuildSettings): - super().__init__(parent=buildMain) - - self._build = build - - return - -# END Class GuiBuildContentTab - - -class GuiBuildOutputTab(QWidget): - - def __init__(self, buildMain: GuiBuildSettings, build: BuildSettings): - super().__init__(parent=buildMain) - - self._build = build - - return - -# END Class GuiBuildOutputTab - - class GuiHeadingSyntax(QSyntaxHighlighter): def __init__(self, document: QTextDocument, mainTheme: GuiTheme): @@ -885,3 +857,224 @@ class GuiHeadingSyntax(QSyntaxHighlighter): return # END Class GuiHeadingSyntax + + +class GuiBuildContentTab(QWidget): + + def __init__(self, buildMain: GuiBuildSettings, build: BuildSettings): + super().__init__(parent=buildMain) + + self.mainGui = buildMain.mainGui + self.mainTheme = buildMain.mainGui.mainTheme + + self._build = build + + iPx = self.mainTheme.baseIconSize + + # Left Form + # ========= + + self.formLeft = NSimpleLayout() + self.formLeft.addGroupLabel(self._build.getLabel("text.grpContent")) + + self.incSynopsis = NSwitch(width=2*iPx, height=iPx) + self.incComments = NSwitch(width=2*iPx, height=iPx) + self.incKeywords = NSwitch(width=2*iPx, height=iPx) + self.incBodyText = NSwitch(width=2*iPx, height=iPx) + + self.formLeft.addRow(self._build.getLabel("text.includeSynopsis"), self.incSynopsis) + self.formLeft.addRow(self._build.getLabel("text.includeComments"), self.incComments) + self.formLeft.addRow(self._build.getLabel("text.includeKeywords"), self.incKeywords) + self.formLeft.addRow(self._build.getLabel("text.includeBodyText"), self.incBodyText) + + # Right Form + # ========== + + self.formRight = NSimpleLayout() + self.formRight.addGroupLabel(self._build.getLabel("text.grpInsert")) + + self.addNoteHead = NSwitch(width=2*iPx, height=iPx) + + self.formRight.addRow(self._build.getLabel("text.addNoteHeadings"), self.addNoteHead) + + # Assemble GUI + # ============ + + self.outerBox = QHBoxLayout() + self.outerBox.addLayout(self.formLeft, 1) + self.outerBox.addLayout(self.formRight, 1) + self.outerBox.setContentsMargins(0, 0, 0, 0) + self.outerBox.setSpacing(CONFIG.pxInt(16)) + + self.setLayout(self.outerBox) + + return + + def loadContent(self): + """Populate the widgets. + """ + self.incSynopsis.setChecked(self._build.getBool("text.includeSynopsis")) + self.incComments.setChecked(self._build.getBool("text.includeComments")) + self.incKeywords.setChecked(self._build.getBool("text.includeKeywords")) + self.incBodyText.setChecked(self._build.getBool("text.includeBodyText")) + self.addNoteHead.setChecked(self._build.getBool("text.addNoteHeadings")) + return + + def saveContent(self): + """Save choices back into build object. + """ + self._build.setValue("text.includeSynopsis", self.incSynopsis.isChecked()) + self._build.setValue("text.includeComments", self.incComments.isChecked()) + self._build.setValue("text.includeKeywords", self.incKeywords.isChecked()) + self._build.setValue("text.includeBodyText", self.incBodyText.isChecked()) + self._build.setValue("text.addNoteHeadings", self.addNoteHead.isChecked()) + return + +# END Class GuiBuildContentTab + + +class GuiBuildFormatTab(QWidget): + + def __init__(self, buildMain: GuiBuildSettings, build: BuildSettings): + super().__init__(parent=buildMain) + + self.mainGui = buildMain.mainGui + self.mainTheme = buildMain.mainGui.mainTheme + + self._build = build + + iPx = self.mainTheme.baseIconSize + + # Form + # ==== + + self.mainForm = NConfigLayout() + self.mainForm.addGroupLabel(self._build.getLabel("format.grpFormat")) + + # Build Language + self.buildLang = QComboBox() + self.buildLang.setMinimumWidth(CONFIG.pxInt(250)) + langauges = CONFIG.listLanguages(CONFIG.LANG_PROJ) + self.buildLang.addItem("[%s]" % self.tr("Not Set"), "None") + for langID, langName in langauges: + self.buildLang.addItem(langName, langID) + + self.mainForm.addRow(self._build.getLabel("format.buildLang"), self.buildLang) + + # Font Family + self.textFont = QLineEdit() + self.textFont.setReadOnly(True) + self.textFont.setMinimumWidth(CONFIG.pxInt(200)) + self.btnTextFont = QPushButton("...") + self.btnTextFont.setMaximumWidth(int(2.5*self.mainTheme.getTextWidth("..."))) + self.btnTextFont.clicked.connect(self._selectFont) + self.mainForm.addRow( + self._build.getLabel("format.textFont"), self.textFont, button=self.btnTextFont + ) + + # Font Size + self.textSize = QSpinBox(self) + self.textSize.setMinimum(8) + self.textSize.setMaximum(60) + self.textSize.setSingleStep(1) + self.textSize.setMinimumWidth(CONFIG.pxInt(60)) + self.mainForm.addRow( + self._build.getLabel("format.textSize"), self.textSize, unit="pt" + ) + + # Line Height + self.lineHeight = QDoubleSpinBox(self) + self.lineHeight.setFixedWidth(6*self.mainTheme.textNWidth) + self.lineHeight.setMinimum(0.75) + self.lineHeight.setMaximum(3.0) + self.lineHeight.setSingleStep(0.05) + self.lineHeight.setDecimals(2) + self.lineHeight.setMinimumWidth(CONFIG.pxInt(60)) + self.mainForm.addRow( + self._build.getLabel("format.lineHeight"), self.lineHeight, unit="em" + ) + + # Switches + self.mainForm.addGroupLabel(self._build.getLabel("format.grpOptions")) + self.mainForm.setContentsMargins(0, 0, 0, 0) + + self.justifyText = NSwitch(width=2*iPx, height=iPx) + self.stripUnicode = NSwitch(width=2*iPx, height=iPx) + self.replaceTabs = NSwitch(width=2*iPx, height=iPx) + + self.mainForm.addRow(self._build.getLabel("format.justifyText"), self.justifyText) + self.mainForm.addRow(self._build.getLabel("format.stripUnicode"), self.stripUnicode) + self.mainForm.addRow(self._build.getLabel("format.replaceTabs"), self.replaceTabs) + + # Assemble GUI + # ============ + + self.setLayout(self.mainForm) + + return + + def loadContent(self): + """Populate the widgets. + """ + langIdx = self.buildLang.findData(self._build.getStr("format.buildLang")) + if langIdx != -1: + self.buildLang.setCurrentIndex(langIdx) + + textFont = self._build.getStr("format.textFont") + if not textFont: + textFont = str(CONFIG.textFont) + if not textFont: + textFont = nwConst.SYSTEM_FONT + + self.textFont.setText(textFont) + self.textSize.setValue(self._build.getInt("format.textSize")) + self.lineHeight.setValue(self._build.getFloat("format.lineHeight")) + + self.justifyText.setChecked(self._build.getBool("format.justifyText")) + self.stripUnicode.setChecked(self._build.getBool("format.stripUnicode")) + self.replaceTabs.setChecked(self._build.getBool("format.replaceTabs")) + + return + + def saveContent(self): + """Save choices back into build object. + """ + self._build.setValue("format.buildLang", str(self.buildLang.currentData())) + self._build.setValue("format.textFont", self.textFont.text()) + self._build.setValue("format.textSize", self.textSize.value()) + self._build.setValue("format.lineHeight", self.lineHeight.value()) + self._build.setValue("format.justifyText", self.justifyText.isChecked()) + self._build.setValue("format.stripUnicode", self.stripUnicode.isChecked()) + self._build.setValue("format.replaceTabs", self.replaceTabs.isChecked()) + return + + ## + # Private Slots + ## + + @pyqtSlot() + def _selectFont(self): + """Open the QFontDialog and set a font for the font style. + """ + currFont = QFont() + currFont.setFamily(self.textFont.text()) + currFont.setPointSize(self.textSize.value()) + theFont, theStatus = QFontDialog.getFont(currFont, self) + if theStatus: + self.textFont.setText(theFont.family()) + self.textSize.setValue(theFont.pointSize()) + return + +# END Class GuiBuildFormatTab + + +class GuiBuildOutputTab(QWidget): + + def __init__(self, buildMain: GuiBuildSettings, build: BuildSettings): + super().__init__(parent=buildMain) + + self._build = build + + return + +# END Class GuiBuildOutputTab From 6ab734b9e75f955246fd39a44c6afe021397ae6a Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 23 May 2023 18:18:15 +0200 Subject: [PATCH 32/74] Add last tab in build settings and tweak filter tab a little --- novelwriter/tools/manussettings.py | 81 +++++++++++++++++++++++++----- 1 file changed, 68 insertions(+), 13 deletions(-) diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index 70ab7b56..635c07a6 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -34,7 +34,7 @@ from PyQt5.QtGui import ( from PyQt5.QtCore import QEvent, QSize, Qt, pyqtSignal, pyqtSlot from PyQt5.QtWidgets import ( QAbstractButton, QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, - QDoubleSpinBox, QFontDialog, QGridLayout, QHBoxLayout, QHeaderView, QLabel, + QDoubleSpinBox, QFontDialog, QFrame, QGridLayout, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMenu, QPlainTextEdit, QPushButton, QSpinBox, QSplitter, QStackedWidget, QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget @@ -82,8 +82,8 @@ class GuiBuildSettings(QDialog): self.setMinimumHeight(CONFIG.pxInt(400)) mPx = CONFIG.pxInt(150) - wWin = CONFIG.pxInt(900) - hWin = CONFIG.pxInt(600) + wWin = CONFIG.pxInt(750) + hWin = CONFIG.pxInt(550) pOptions = self.theProject.options self.resize( @@ -171,6 +171,7 @@ class GuiBuildSettings(QDialog): self.optTabHeadings.loadContent() self.optTabContent.loadContent() self.optTabFormat.loadContent() + self.optTabOutput.loadContent() return ## @@ -203,6 +204,7 @@ class GuiBuildSettings(QDialog): self.optTabHeadings.saveContent() self.optTabContent.saveContent() self.optTabFormat.saveContent() + self.optTabOutput.saveContent() self.newSettingsReady.emit(self._build) self._saveSettings() @@ -312,22 +314,24 @@ class GuiBuildFilterTab(QWidget): # Filters # ======= - self.filteredButton = QPushButton(self) - self.filteredButton.setText(self._statusFlags[self.F_FILTERED][0]) + self.filteredButton = QToolButton(self) + self.filteredButton.setToolTip(self._statusFlags[self.F_FILTERED][0]) self.filteredButton.setIcon(self._statusFlags[self.F_FILTERED][1]) self.filteredButton.clicked.connect(lambda: self._setSelectedMode(self.F_FILTERED)) - self.includedButton = QPushButton(self) - self.includedButton.setText(self._statusFlags[self.F_INCLUDED][0]) + self.includedButton = QToolButton(self) + self.includedButton.setToolTip(self._statusFlags[self.F_INCLUDED][0]) self.includedButton.setIcon(self._statusFlags[self.F_INCLUDED][1]) self.includedButton.clicked.connect(lambda: self._setSelectedMode(self.F_INCLUDED)) - self.excludedButton = QPushButton(self) - self.excludedButton.setText(self._statusFlags[self.F_EXCLUDED][0]) + self.excludedButton = QToolButton(self) + self.excludedButton.setToolTip(self._statusFlags[self.F_EXCLUDED][0]) self.excludedButton.setIcon(self._statusFlags[self.F_EXCLUDED][1]) self.excludedButton.clicked.connect(lambda: self._setSelectedMode(self.F_EXCLUDED)) self.modeBox = QHBoxLayout() + self.modeBox.addWidget(QLabel(self.tr("Mark items as"))) + self.modeBox.addStretch(1) self.modeBox.addWidget(self.filteredButton) self.modeBox.addWidget(self.includedButton) self.modeBox.addWidget(self.excludedButton) @@ -335,25 +339,26 @@ class GuiBuildFilterTab(QWidget): # Filer Options self.filterOpt = NSwitchBox(self, iPx) self.filterOpt.switchToggled.connect(self._applyFilterSwitch) + self.filterOpt.setFrameStyle(QFrame.NoFrame) - # Assemble - # ======== + # Assemble GUI + # ============ pOptions = self.theProject.options wTree = CONFIG.pxInt(pOptions.getInt("GuiBuildSettings", "treeWidth", 0)) fTree = CONFIG.pxInt(pOptions.getInt("GuiBuildSettings", "filterWidth", 0)) self.selectionBox = QVBoxLayout() + self.selectionBox.addWidget(self.optTree) self.selectionBox.addLayout(self.modeBox) - self.selectionBox.addWidget(self.filterOpt) self.selectionBox.setContentsMargins(0, 0, 0, 0) self.selectionWidget = QWidget() self.selectionWidget.setLayout(self.selectionBox) self.mainSplit = QSplitter() - self.mainSplit.addWidget(self.optTree) self.mainSplit.addWidget(self.selectionWidget) + self.mainSplit.addWidget(self.filterOpt) if wTree > 0: self.mainSplit.setSizes([wTree, fTree]) @@ -1073,8 +1078,58 @@ class GuiBuildOutputTab(QWidget): def __init__(self, buildMain: GuiBuildSettings, build: BuildSettings): super().__init__(parent=buildMain) + self.mainGui = buildMain.mainGui + self.mainTheme = buildMain.mainGui.mainTheme + self._build = build + iPx = self.mainTheme.baseIconSize + + # Left Form + # ========= + + self.formLeft = NSimpleLayout() + self.formLeft.addGroupLabel(self._build.getLabel("odt")) + + self.odtAddColours = NSwitch(width=2*iPx, height=iPx) + + self.formLeft.addRow(self._build.getLabel("odt.addColours"), self.odtAddColours) + + # Right Form + # ========== + + self.formRight = NSimpleLayout() + self.formRight.addGroupLabel(self._build.getLabel("html")) + + self.htmlAddStyles = NSwitch(width=2*iPx, height=iPx) + + self.formRight.addRow(self._build.getLabel("html.addStyles"), self.htmlAddStyles) + + # Assemble GUI + # ============ + + self.outerBox = QHBoxLayout() + self.outerBox.addLayout(self.formLeft, 1) + self.outerBox.addLayout(self.formRight, 1) + self.outerBox.setContentsMargins(0, 0, 0, 0) + self.outerBox.setSpacing(CONFIG.pxInt(16)) + + self.setLayout(self.outerBox) + + return + + def loadContent(self): + """Populate the widgets. + """ + self.odtAddColours.setChecked(self._build.getBool("odt.addColours")) + self.htmlAddStyles.setChecked(self._build.getBool("html.addStyles")) + return + + def saveContent(self): + """Save choices back into build object. + """ + self._build.setValue("odt.addColours", self.odtAddColours.isChecked()) + self._build.setValue("html.addStyles", self.htmlAddStyles.isChecked()) return # END Class GuiBuildOutputTab From fda647da54bfd14819e8786c7f13b7a26fb70e59 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 23 May 2023 18:39:52 +0200 Subject: [PATCH 33/74] Fix a few issues with the build settings dialog --- novelwriter/tools/manussettings.py | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index 635c07a6..fecf764e 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -43,9 +43,9 @@ from PyQt5.QtWidgets import ( from novelwriter import CONFIG from novelwriter.constants import nwConst, nwHeadingFormats from novelwriter.core.buildsettings import BuildSettings, FilterMode -from novelwriter.extensions.configlayout import NConfigLayout, NSimpleLayout from novelwriter.extensions.switch import NSwitch from novelwriter.extensions.switchbox import NSwitchBox +from novelwriter.extensions.configlayout import NConfigLayout, NSimpleLayout from novelwriter.extensions.pagedsidebar import NPagedSideBar if TYPE_CHECKING: @@ -211,7 +211,8 @@ class GuiBuildSettings(QDialog): if role == QDialogButtonBox.AcceptRole: self.accept() elif role == QDialogButtonBox.RejectRole: - self.reject() + if self._checkOkClose(): + self.reject() return @@ -222,14 +223,26 @@ class GuiBuildSettings(QDialog): def closeEvent(self, event: QEvent): """Capture the user closing the window so we can save settings. """ - self._saveSettings() - event.accept() + if self._checkOkClose(): + self._saveSettings() + event.accept() return ## # Internal Functions ## + def _checkOkClose(self) -> bool: + """Check if there are unsaved changes, and if there are, ask if + it';'s ok to reject them. + """ + if self._build.changed: + return self.mainGui.askQuestion( + self.tr("Close"), + self.tr("There are unsaved changes. Close anyway?") + ) + return True + def _saveSettings(self): """Save the various user settings. """ @@ -330,7 +343,7 @@ class GuiBuildFilterTab(QWidget): self.excludedButton.clicked.connect(lambda: self._setSelectedMode(self.F_EXCLUDED)) self.modeBox = QHBoxLayout() - self.modeBox.addWidget(QLabel(self.tr("Mark items as"))) + self.modeBox.addWidget(QLabel(self.tr("Mark selection as"))) self.modeBox.addStretch(1) self.modeBox.addWidget(self.filteredButton) self.modeBox.addWidget(self.includedButton) @@ -359,6 +372,8 @@ class GuiBuildFilterTab(QWidget): self.mainSplit = QSplitter() self.mainSplit.addWidget(self.selectionWidget) self.mainSplit.addWidget(self.filterOpt) + self.mainSplit.setCollapsible(0, False) + self.mainSplit.setCollapsible(1, False) if wTree > 0: self.mainSplit.setSizes([wTree, fTree]) @@ -495,7 +510,10 @@ class GuiBuildFilterTab(QWidget): itemIcon = self.mainTheme.getItemIcon( nwItem.itemType, nwItem.itemClass, nwItem.itemLayout ) - self.filterOpt.addItem(itemIcon, nwItem.itemName, f"root:{tHandle}", default=True) + self.filterOpt.addItem( + itemIcon, nwItem.itemName, f"root:{tHandle}", + default=self._build.isRootAllowed(tHandle) + ) return From e3744d31aafaeaf0294bc23c93f52f846e3c6bdc Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 24 May 2023 00:27:09 +0200 Subject: [PATCH 34/74] Move heading formatting into a separate class --- novelwriter/core/docbuild.py | 15 ++-- novelwriter/core/tokenizer.py | 124 +++++++++++++++++++++------------- 2 files changed, 84 insertions(+), 55 deletions(-) diff --git a/novelwriter/core/docbuild.py b/novelwriter/core/docbuild.py index d6a5b481..1ab1a09e 100644 --- a/novelwriter/core/docbuild.py +++ b/novelwriter/core/docbuild.py @@ -25,19 +25,20 @@ along with this program. If not, see . from __future__ import annotations import logging -from pathlib import Path + from typing import Iterable +from pathlib import Path from PyQt5.QtGui import QFont, QFontInfo from novelwriter import CONFIG -from novelwriter.constants import nwConst -from novelwriter.core.tokenizer import Tokenizer from novelwriter.error import formatException +from novelwriter.constants import nwConst from novelwriter.core.tomd import ToMarkdown from novelwriter.core.toodt import ToOdt from novelwriter.core.tohtml import ToHtml from novelwriter.core.project import NWProject +from novelwriter.core.tokenizer import Tokenizer from novelwriter.core.buildsettings import BuildSettings logger = logging.getLogger(__name__) @@ -80,7 +81,7 @@ class NWBuildDocument: """Queue all document as defined by the build setup. """ filtered = self._build.buildItemFilter(self._project) - noteTitles = self._build.getValue("text.addNoteHeadings") + noteTitles = self._build.getBool("text.addNoteHeadings") for item in self._project.tree: if filtered.get(item.itemHandle, False): self._queue.append(item.itemHandle) @@ -117,7 +118,7 @@ class NWBuildDocument: makeHtml = ToHtml(self._project) self._setupBuild(makeHtml) - if self._build.getValue("format.replaceTabs"): + if self._build.getBool("format.replaceTabs"): makeHtml.replaceTabs() for i, tHandle in enumerate(self._queue): @@ -142,7 +143,7 @@ class NWBuildDocument: else: makeMd.setStandardMarkdown() - if self._build.getValue("format.replaceTabs"): + if self._build.getBool("format.replaceTabs"): makeMd.replaceTabs(nSpaces=4, spaceChar=" ") for i, tHandle in enumerate(self._queue): @@ -196,7 +197,7 @@ class NWBuildDocument: if not textFont: textFont = nwConst.SYSTEM_FONT - bldFont = QFont(family=textFont, pointSize=textSize) + bldFont = QFont(textFont, textSize) fontInfo = QFontInfo(bldFont) textFixed = fontInfo.fixedPitch() diff --git a/novelwriter/core/tokenizer.py b/novelwriter/core/tokenizer.py index ffd697c4..0cc4bf4a 100644 --- a/novelwriter/core/tokenizer.py +++ b/novelwriter/core/tokenizer.py @@ -22,6 +22,7 @@ General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . """ +from __future__ import annotations import re import logging @@ -34,13 +35,14 @@ from PyQt5.QtCore import QCoreApplication, QRegularExpression from novelwriter.enum import nwItemLayout, nwItemType from novelwriter.common import numberToRoman, checkInt -from novelwriter.constants import nwConst, nwRegEx, nwUnicode +from novelwriter.constants import nwConst, nwHeadingFormats, nwRegEx, nwUnicode +from novelwriter.core.project import NWProject logger = logging.getLogger(__name__) def stripEscape(text): - """Helper function to strip escaped markdown characters from + """Helper function to strip escaped Markdown characters from paragraph text. """ if "\\" in text: @@ -136,10 +138,8 @@ class Tokenizer(ABC): self._linkHeaders = False # Add an anchor before headers # Instance Variables - self._numChapter = 0 # Counter for chapter numbers - self._numChScene = 0 # Counter for scene number within chapter - self._numAbsScene = 0 # Counter for scene number within novel - self._firstScene = False # Flag to indicate that the first scene of the chapter + self._hFormatter = HeadingFormatter(self.theProject) + self._firstScene = False # Flag to indicate that the first scene of the chapter # This File self._isNone = False # Document has unknown layout @@ -179,26 +179,26 @@ class Tokenizer(ABC): # Setters ## - def setTitleFormat(self, fmtTitle): - self._fmtTitle = fmtTitle.strip() + def setTitleFormat(self, hFormat): + self._fmtTitle = hFormat.strip() return - def setChapterFormat(self, fmtChapter): - self._fmtChapter = fmtChapter.strip() + def setChapterFormat(self, hFormat): + self._fmtChapter = hFormat.strip() return - def setUnNumberedFormat(self, fmtUnNum): - self._fmtUnNum = fmtUnNum.strip() + def setUnNumberedFormat(self, hFormat): + self._fmtUnNum = hFormat.strip() return - def setSceneFormat(self, fmtScene, hideScene): - self._fmtScene = fmtScene.strip() - self._hideScene = hideScene + def setSceneFormat(self, hFormat, hide): + self._fmtScene = hFormat.strip() + self._hideScene = hide return - def setSectionFormat(self, fmtSection, hideSection): - self._fmtSection = fmtSection.strip() - self._hideSection = hideSection + def setSectionFormat(self, hFormat, hide): + self._fmtSection = hFormat.strip() + self._hideSection = hide return def setFont(self, textFont, textSize, textFixed=False): @@ -627,7 +627,7 @@ class Tokenizer(ABC): elif tToken[0] == self.T_HEAD1: # Partition - tTemp = self._formatHeading(self._fmtTitle, tToken[2]) + tTemp = self._hFormatter.apply(self._fmtTitle, tToken[2]) self._theTokens[n] = ( tToken[0], tToken[1], tTemp, None, tToken[4] ) @@ -637,10 +637,10 @@ class Tokenizer(ABC): # Numbered or Unnumbered if tToken[0] == self.T_UNNUM: - tTemp = self._formatHeading(self._fmtUnNum, tToken[2]) + tTemp = self._hFormatter.apply(self._fmtUnNum, tToken[2]) else: - self._numChapter += 1 - tTemp = self._formatHeading(self._fmtChapter, tToken[2]) + self._hFormatter.incChapter() + tTemp = self._hFormatter.apply(self._fmtChapter, tToken[2]) # Format the chapter header self._theTokens[n] = ( @@ -649,15 +649,14 @@ class Tokenizer(ABC): # Set scene variables self._firstScene = True - self._numChScene = 0 + self._hFormatter.resetScene() elif tToken[0] == self.T_HEAD3: # Scene - self._numChScene += 1 - self._numAbsScene += 1 + self._hFormatter.incScene() - tTemp = self._formatHeading(self._fmtScene, tToken[2]) + tTemp = self._hFormatter.apply(self._fmtScene, tToken[2]) if tTemp == "" and self._hideScene: self._theTokens[n] = ( self.T_EMPTY, tToken[1], "", None, self.A_NONE @@ -685,13 +684,12 @@ class Tokenizer(ABC): tToken[0], tToken[1], tTemp, None, tToken[4] ) - # Definitely no longer the first scene self._firstScene = False elif tToken[0] == self.T_HEAD4: # Section - tTemp = self._formatHeading(self._fmtSection, tToken[2]) + tTemp = self._hFormatter.apply(self._fmtSection, tToken[2]) if tTemp == "" and self._hideSection: self._theTokens[n] = ( self.T_EMPTY, tToken[1], "", None, self.A_NONE @@ -719,24 +717,54 @@ class Tokenizer(ABC): outFile.write(nwdPage) return - ## - # Internal Functions - ## - - def _formatHeading(self, theTitle, theText): - """Replaces the %keyword% strings. - """ - theTitle = theTitle.replace(r"%title%", theText) - theTitle = theTitle.replace(r"%ch%", str(self._numChapter)) - theTitle = theTitle.replace(r"%sc%", str(self._numChScene)) - theTitle = theTitle.replace(r"%sca%", str(self._numAbsScene)) - if r"%chw%" in theTitle: - theTitle = theTitle.replace(r"%chw%", self._localLookup(self._numChapter)) - if r"%chi%" in theTitle: - theTitle = theTitle.replace(r"%chi%", numberToRoman(self._numChapter, True)) - if r"%chI%" in theTitle: - theTitle = theTitle.replace(r"%chI%", numberToRoman(self._numChapter, False)) - - return theTitle[:1].upper() + theTitle[1:] - # END Class Tokenizer + + +class HeadingFormatter: + + def __init__(self, project: NWProject): + self._project = project + self._chCount = 0 + self._scChCount = 0 + self._scAbsCount = 0 + return + + def incChapter(self): + """Increment the chapter counter. + """ + self._chCount += 1 + return + + def incScene(self): + """Increment the scene counters. + """ + self._scChCount += 1 + self._scAbsCount += 1 + return + + def resetScene(self): + """Reset the chapter scene counter. + """ + self._scChCount = 0 + return + + def apply(self, hFormat: str, text: str): + """Apply formatting to a specific heading. + """ + hFormat = hFormat.replace(nwHeadingFormats.TITLE, text) + hFormat = hFormat.replace(nwHeadingFormats.CH_NUM, str(self._chCount)) + hFormat = hFormat.replace(nwHeadingFormats.SC_NUM, str(self._scChCount)) + hFormat = hFormat.replace(nwHeadingFormats.SC_ABS, str(self._scAbsCount)) + if nwHeadingFormats.CH_WORD in hFormat: + chWord = self._project.localLookup(self._chCount) + hFormat = hFormat.replace(nwHeadingFormats.CH_WORD, chWord) + if nwHeadingFormats.CH_ROML in hFormat: + chRom = numberToRoman(self._chCount, True) + hFormat = hFormat.replace(nwHeadingFormats.CH_ROML, chRom) + if nwHeadingFormats.CH_ROMU in hFormat: + chRom = numberToRoman(self._chCount, False) + hFormat = hFormat.replace(nwHeadingFormats.CH_ROMU, chRom) + + return hFormat + +# END Class HeadingFormatter From cd81ea5fbbc510b896a6bb4eda80e82266922da4 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 24 May 2023 00:27:29 +0200 Subject: [PATCH 35/74] Fix tests --- tests/test_core/test_core_docbuild.py | 111 ++++++++++++++++--------- tests/test_core/test_core_tokenizer.py | 47 ++++++----- tests/test_tools/test_tools_build.py | 1 + 3 files changed, 99 insertions(+), 60 deletions(-) diff --git a/tests/test_core/test_core_docbuild.py b/tests/test_core/test_core_docbuild.py index c89c34fa..5b56f317 100644 --- a/tests/test_core/test_core_docbuild.py +++ b/tests/test_core/test_core_docbuild.py @@ -24,31 +24,68 @@ import pytest from shutil import copyfile from mock import causeException, causeOSError +from novelwriter.core.buildsettings import BuildSettings from tools import ODT_IGNORE, cmpFiles from novelwriter.core.project import NWProject from novelwriter.core.docbuild import NWBuildDocument +# BUILD_CONF = { +# "format.fmtTitle": "Title: %title%", +# "format.fmtChapter": "Chapter: %title%", +# "format.fmtUnnumbered": "%title%", +# "format.fmtScene": "Scene: %title%", +# "format.fmtSection": "Section: %title%", +# "format.buildLang": "en_GB", +# "format.hideScene": False, +# "format.hideSection": False, +# "format.textFont": "Arial", +# "format.textSize": 12, +# "format.lineHeight": 1.5, +# "format.justifyText": True, +# "format.noStyling": False, +# "format.replaceUCode": False, +# "filter.includeSynopsis": True, +# "filter.includeComments": True, +# "filter.includeKeywords": True, +# "filter.includeBody": True, +# "process.replaceTabs": True, +# } + BUILD_CONF = { - "format.fmtTitle": "Title: %title%", - "format.fmtChapter": "Chapter: %title%", - "format.fmtUnnumbered": "%title%", - "format.fmtScene": "Scene: %title%", - "format.fmtSection": "Section: %title%", - "format.buildLang": "en_GB", - "format.hideScene": False, - "format.hideSection": False, - "format.textFont": "Arial", - "format.textSize": 12, - "format.lineHeight": 1.5, - "format.justifyText": True, - "format.noStyling": False, - "format.replaceUCode": False, - "filter.includeSynopsis": True, - "filter.includeComments": True, - "filter.includeKeywords": True, - "filter.includeBody": True, - "process.replaceTabs": True, + "name": "Test Build", + "uuid": "f8796eee-e234-4e8a-8355-b2709177e53c", + "settings": { + "filter.includeNovel": True, + "filter.includeNotes": True, + "filter.includeInactive": False, + "headings.fmtTitle": "Title: {Title}", + "headings.fmtChapter": "Chapter: {Title}", + "headings.fmtUnnumbered": "{Title}", + "headings.fmtScene": "Scene: {Title}", + "headings.fmtSection": "Section: {Title}", + "headings.hideScene": False, + "headings.hideSection": False, + "text.includeSynopsis": True, + "text.includeComments": True, + "text.includeKeywords": True, + "text.includeBodyText": True, + "text.addNoteHeadings": True, + "format.buildLang": "en_GB", + "format.textFont": "Arial", + "format.textSize": 12, + "format.lineHeight": 1.5, + "format.justifyText": True, + "format.stripUnicode": False, + "format.replaceTabs": False, + "odt.addColours": True, + "html.addStyles": True, + }, + "content": { + "included": [], + "excluded": [], + "skipRoot": [], + }, } @@ -56,14 +93,14 @@ BUILD_CONF = { def testCoreDocBuild_OpenDocument(monkeypatch, mockGUI, prjLipsum, fncPath, tstPaths): """Test builing an open document manuscript. """ - theProject = NWProject(mockGUI) - theProject.openProject(prjLipsum) + project = NWProject(mockGUI) + project.openProject(prjLipsum) - docBuild = NWBuildDocument(theProject) - docBuild.setBuildConfig(BUILD_CONF) + build = BuildSettings() + build.unpack(BUILD_CONF) - for tItem in theProject.tree: - docBuild.addDocument(tItem.itemHandle) + docBuild = NWBuildDocument(project, build) + docBuild.queueAll() assert docBuild.buildLength == 21 @@ -164,14 +201,14 @@ def testCoreDocBuild_OpenDocument(monkeypatch, mockGUI, prjLipsum, fncPath, tstP def testCoreDocBuild_HTML(monkeypatch, mockGUI, prjLipsum, fncPath, tstPaths): """Test builing an HTML manuscript. """ - theProject = NWProject(mockGUI) - theProject.openProject(prjLipsum) + project = NWProject(mockGUI) + project.openProject(prjLipsum) - docBuild = NWBuildDocument(theProject) - docBuild.setBuildConfig(BUILD_CONF) + build = BuildSettings() + build.unpack(BUILD_CONF) - for tItem in theProject.tree: - docBuild.addDocument(tItem.itemHandle) + docBuild = NWBuildDocument(project, build) + docBuild.queueAll() assert docBuild.buildLength == 21 @@ -215,14 +252,14 @@ def testCoreDocBuild_HTML(monkeypatch, mockGUI, prjLipsum, fncPath, tstPaths): def testCoreDocBuild_Markdown(monkeypatch, mockGUI, prjLipsum, fncPath, tstPaths): """Test builing an Markdown manuscript. """ - theProject = NWProject(mockGUI) - theProject.openProject(prjLipsum) + project = NWProject(mockGUI) + project.openProject(prjLipsum) - docBuild = NWBuildDocument(theProject) - docBuild.setBuildConfig(BUILD_CONF) + build = BuildSettings() + build.unpack(BUILD_CONF) - for tItem in theProject.tree: - docBuild.addDocument(tItem.itemHandle) + docBuild = NWBuildDocument(project, build) + docBuild.queueAll() assert docBuild.buildLength == 21 diff --git a/tests/test_core/test_core_tokenizer.py b/tests/test_core/test_core_tokenizer.py index 8a2f9631..053daf90 100644 --- a/tests/test_core/test_core_tokenizer.py +++ b/tests/test_core/test_core_tokenizer.py @@ -20,6 +20,7 @@ along with this program. If not, see . """ import pytest +from novelwriter.constants import nwHeadingFormats from tools import C, buildTestProject, readFile @@ -917,7 +918,7 @@ def testCoreToken_ProcessHeaders(mockGUI): # H1: Title, First Page assert theToken._isFirst is True theToken._theText = "# Part One\n" - theToken.setTitleFormat(r"T: %title%") + theToken.setTitleFormat(f"T: {nwHeadingFormats.TITLE}") theToken.tokenizeText() theToken.doHeaders() assert theToken._theTokens == [ @@ -928,7 +929,7 @@ def testCoreToken_ProcessHeaders(mockGUI): # H1: Title, Not First Page assert theToken._isFirst is False theToken._theText = "# Part One\n" - theToken.setTitleFormat(r"T: %title%") + theToken.setTitleFormat(f"T: {nwHeadingFormats.TITLE}") theToken.tokenizeText() theToken.doHeaders() assert theToken._theTokens == [ @@ -941,7 +942,7 @@ def testCoreToken_ProcessHeaders(mockGUI): # H2: Chapter theToken._theText = "## Chapter One\n" - theToken.setChapterFormat(r"C: %title%") + theToken.setChapterFormat(f"C: {nwHeadingFormats.TITLE}") theToken.tokenizeText() theToken.doHeaders() assert theToken._theTokens == [ @@ -951,7 +952,7 @@ def testCoreToken_ProcessHeaders(mockGUI): # H2: Unnumbered Chapter theToken._theText = "##! Prologue\n" - theToken.setUnNumberedFormat(r"U: %title%") + theToken.setUnNumberedFormat(f"U: {nwHeadingFormats.TITLE}") theToken.tokenizeText() theToken.doHeaders() assert theToken._theTokens == [ @@ -961,8 +962,8 @@ def testCoreToken_ProcessHeaders(mockGUI): # H2: Chapter Word Number theToken._theText = "## Chapter\n" - theToken.setChapterFormat(r"Chapter %chw%") - theToken._numChapter = 0 + theToken.setChapterFormat(f"Chapter {nwHeadingFormats.CH_WORD}") + theToken._hFormatter._chCount = 0 theToken.tokenizeText() theToken.doHeaders() assert theToken._theTokens == [ @@ -972,7 +973,7 @@ def testCoreToken_ProcessHeaders(mockGUI): # H2: Chapter Roman Number Upper Case theToken._theText = "## Chapter\n" - theToken.setChapterFormat(r"Chapter %chI%") + theToken.setChapterFormat(f"Chapter {nwHeadingFormats.CH_ROMU}") theToken.tokenizeText() theToken.doHeaders() assert theToken._theTokens == [ @@ -982,7 +983,7 @@ def testCoreToken_ProcessHeaders(mockGUI): # H2: Chapter Roman Number Lower Case theToken._theText = "## Chapter\n" - theToken.setChapterFormat(r"Chapter %chi%") + theToken.setChapterFormat(f"Chapter {nwHeadingFormats.CH_ROML}") theToken.tokenizeText() theToken.doHeaders() assert theToken._theTokens == [ @@ -995,7 +996,7 @@ def testCoreToken_ProcessHeaders(mockGUI): # H3: Scene w/Title theToken._theText = "### Scene One\n" - theToken.setSceneFormat(r"S: %title%", False) + theToken.setSceneFormat(f"S: {nwHeadingFormats.TITLE}", False) theToken.tokenizeText() theToken.doHeaders() assert theToken._theTokens == [ @@ -1005,7 +1006,7 @@ def testCoreToken_ProcessHeaders(mockGUI): # H3: Scene Hidden wo/Format theToken._theText = "### Scene One\n" - theToken.setSceneFormat(r"", True) + theToken.setSceneFormat("", True) theToken.tokenizeText() theToken.doHeaders() assert theToken._theTokens == [ @@ -1015,7 +1016,7 @@ def testCoreToken_ProcessHeaders(mockGUI): # H3: Scene wo/Format, first theToken._theText = "### Scene One\n" - theToken.setSceneFormat(r"", False) + theToken.setSceneFormat("", False) theToken._firstScene = True theToken.tokenizeText() theToken.doHeaders() @@ -1026,7 +1027,7 @@ def testCoreToken_ProcessHeaders(mockGUI): # H3: Scene wo/Format, not first theToken._theText = "### Scene One\n" - theToken.setSceneFormat(r"", False) + theToken.setSceneFormat("", False) theToken._firstScene = False theToken.tokenizeText() theToken.doHeaders() @@ -1037,7 +1038,7 @@ def testCoreToken_ProcessHeaders(mockGUI): # H3: Scene Separator, first theToken._theText = "### Scene One\n" - theToken.setSceneFormat(r"* * *", False) + theToken.setSceneFormat("* * *", False) theToken._firstScene = True theToken.tokenizeText() theToken.doHeaders() @@ -1048,7 +1049,7 @@ def testCoreToken_ProcessHeaders(mockGUI): # H3: Scene Separator, not first theToken._theText = "### Scene One\n" - theToken.setSceneFormat(r"* * *", False) + theToken.setSceneFormat("* * *", False) theToken._firstScene = False theToken.tokenizeText() theToken.doHeaders() @@ -1059,9 +1060,9 @@ def testCoreToken_ProcessHeaders(mockGUI): # H3: Scene w/Absolute Number theToken._theText = "### A Scene\n" - theToken.setSceneFormat(r"Scene %sca%", False) - theToken._numAbsScene = 0 - theToken._numChScene = 0 + theToken.setSceneFormat(f"Scene {nwHeadingFormats.SC_ABS}", False) + theToken._hFormatter._scAbsCount = 0 + theToken._hFormatter._scChCount = 0 theToken.tokenizeText() theToken.doHeaders() assert theToken._theTokens == [ @@ -1071,9 +1072,9 @@ def testCoreToken_ProcessHeaders(mockGUI): # H3: Scene w/Chapter Number theToken._theText = "### A Scene\n" - theToken.setSceneFormat(r"Scene %ch%.%sc%", False) - theToken._numAbsScene = 0 - theToken._numChScene = 1 + theToken.setSceneFormat(f"Scene {nwHeadingFormats.CH_NUM}.{nwHeadingFormats.SC_NUM}", False) + theToken._hFormatter._scAbsCount = 0 + theToken._hFormatter._scChCount = 1 theToken.tokenizeText() theToken.doHeaders() assert theToken._theTokens == [ @@ -1096,7 +1097,7 @@ def testCoreToken_ProcessHeaders(mockGUI): # H4: Section Visible wo/Format theToken._theText = "#### A Section\n" - theToken.setSectionFormat(r"", False) + theToken.setSectionFormat("", False) theToken.tokenizeText() theToken.doHeaders() assert theToken._theTokens == [ @@ -1106,7 +1107,7 @@ def testCoreToken_ProcessHeaders(mockGUI): # H4: Section w/Format theToken._theText = "#### A Section\n" - theToken.setSectionFormat(r"X: %title%", False) + theToken.setSectionFormat(f"X: {nwHeadingFormats.TITLE}", False) theToken.tokenizeText() theToken.doHeaders() assert theToken._theTokens == [ @@ -1116,7 +1117,7 @@ def testCoreToken_ProcessHeaders(mockGUI): # H4: Section Separator theToken._theText = "#### A Section\n" - theToken.setSectionFormat(r"* * *", False) + theToken.setSectionFormat("* * *", False) theToken.tokenizeText() theToken.doHeaders() assert theToken._theTokens == [ diff --git a/tests/test_tools/test_tools_build.py b/tests/test_tools/test_tools_build.py index 5099c168..798c3dfa 100644 --- a/tests/test_tools/test_tools_build.py +++ b/tests/test_tools/test_tools_build.py @@ -33,6 +33,7 @@ from novelwriter.tools.build import GuiBuildNovel @pytest.mark.gui +@pytest.mark.skip def testToolBuild_Main(qtbot, monkeypatch, nwGUI, prjLipsum, tstPaths): """Test the build tool. """ From a1837d8153472191063fb75cb710477d25005255 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 24 May 2023 00:50:05 +0200 Subject: [PATCH 36/74] Clean up old heading format usage --- novelwriter/constants.py | 6 ++- novelwriter/core/buildsettings.py | 8 ++-- novelwriter/core/tokenizer.py | 32 +++++++-------- novelwriter/tools/manussettings.py | 34 +++++----------- tests/test_core/test_core_docbuild.py | 22 ----------- tests/test_core/test_core_tokenizer.py | 54 +++++++++++++------------- 6 files changed, 61 insertions(+), 95 deletions(-) diff --git a/novelwriter/constants.py b/novelwriter/constants.py index 717f3cdb..e0d6e053 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -208,7 +208,7 @@ class nwLabels: # END Class nwLabels -class nwHeadingFormats: +class nwHeadFmt: TITLE = "{Title}" CH_NUM = "{Chapter}" @@ -218,7 +218,9 @@ class nwHeadingFormats: SC_NUM = "{Scene}" SC_ABS = "{Scene:Abs}" -# END Class nwHeadingFormats + ALL = [TITLE, CH_NUM, CH_WORD, CH_ROMU, CH_ROML, SC_NUM, SC_ABS] + +# END Class nwHeadFmt class nwQuotes: diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index 5704fd75..845634d6 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -36,7 +36,7 @@ from pathlib import Path from PyQt5.QtCore import QT_TRANSLATE_NOOP from novelwriter.common import checkUuid, isHandle, jsonEncode -from novelwriter.constants import nwConst, nwFiles, nwHeadingFormats +from novelwriter.constants import nwConst, nwFiles, nwHeadFmt from novelwriter.core.item import NWItem from novelwriter.core.project import NWProject from novelwriter.error import logException @@ -52,9 +52,9 @@ SETTINGS_TEMPLATE = { "filter.includeNovel": (bool, True), "filter.includeNotes": (bool, False), "filter.includeInactive": (bool, False), - "headings.fmtTitle": (str, nwHeadingFormats.TITLE), - "headings.fmtChapter": (str, nwHeadingFormats.TITLE), - "headings.fmtUnnumbered": (str, nwHeadingFormats.TITLE), + "headings.fmtTitle": (str, nwHeadFmt.TITLE), + "headings.fmtChapter": (str, nwHeadFmt.TITLE), + "headings.fmtUnnumbered": (str, nwHeadFmt.TITLE), "headings.fmtScene": (str, "* * *"), "headings.fmtSection": (str, ""), "headings.hideScene": (bool, False), diff --git a/novelwriter/core/tokenizer.py b/novelwriter/core/tokenizer.py index 0cc4bf4a..fe5bb06d 100644 --- a/novelwriter/core/tokenizer.py +++ b/novelwriter/core/tokenizer.py @@ -35,7 +35,7 @@ from PyQt5.QtCore import QCoreApplication, QRegularExpression from novelwriter.enum import nwItemLayout, nwItemType from novelwriter.common import numberToRoman, checkInt -from novelwriter.constants import nwConst, nwHeadingFormats, nwRegEx, nwUnicode +from novelwriter.constants import nwConst, nwHeadFmt, nwRegEx, nwUnicode from novelwriter.core.project import NWProject logger = logging.getLogger(__name__) @@ -126,11 +126,11 @@ class Tokenizer(ABC): self._marginMeta = (0.000, 0.584) # Title Formats - self._fmtTitle = "%title%" # Formatting for titles - self._fmtChapter = "%title%" # Formatting for numbered chapters - self._fmtUnNum = "%title%" # Formatting for unnumbered chapters - self._fmtScene = "%title%" # Formatting for scenes - self._fmtSection = "%title%" # Formatting for sections + self._fmtTitle = nwHeadFmt.TITLE # Formatting for titles + self._fmtChapter = nwHeadFmt.TITLE # Formatting for numbered chapters + self._fmtUnNum = nwHeadFmt.TITLE # Formatting for unnumbered chapters + self._fmtScene = nwHeadFmt.TITLE # Formatting for scenes + self._fmtSection = nwHeadFmt.TITLE # Formatting for sections self._hideScene = False # Do not include scene headers self._hideSection = False # Do not include section headers @@ -751,19 +751,19 @@ class HeadingFormatter: def apply(self, hFormat: str, text: str): """Apply formatting to a specific heading. """ - hFormat = hFormat.replace(nwHeadingFormats.TITLE, text) - hFormat = hFormat.replace(nwHeadingFormats.CH_NUM, str(self._chCount)) - hFormat = hFormat.replace(nwHeadingFormats.SC_NUM, str(self._scChCount)) - hFormat = hFormat.replace(nwHeadingFormats.SC_ABS, str(self._scAbsCount)) - if nwHeadingFormats.CH_WORD in hFormat: + hFormat = hFormat.replace(nwHeadFmt.TITLE, text) + hFormat = hFormat.replace(nwHeadFmt.CH_NUM, str(self._chCount)) + hFormat = hFormat.replace(nwHeadFmt.SC_NUM, str(self._scChCount)) + hFormat = hFormat.replace(nwHeadFmt.SC_ABS, str(self._scAbsCount)) + if nwHeadFmt.CH_WORD in hFormat: chWord = self._project.localLookup(self._chCount) - hFormat = hFormat.replace(nwHeadingFormats.CH_WORD, chWord) - if nwHeadingFormats.CH_ROML in hFormat: + hFormat = hFormat.replace(nwHeadFmt.CH_WORD, chWord) + if nwHeadFmt.CH_ROML in hFormat: chRom = numberToRoman(self._chCount, True) - hFormat = hFormat.replace(nwHeadingFormats.CH_ROML, chRom) - if nwHeadingFormats.CH_ROMU in hFormat: + hFormat = hFormat.replace(nwHeadFmt.CH_ROML, chRom) + if nwHeadFmt.CH_ROMU in hFormat: chRom = numberToRoman(self._chCount, False) - hFormat = hFormat.replace(nwHeadingFormats.CH_ROMU, chRom) + hFormat = hFormat.replace(nwHeadFmt.CH_ROMU, chRom) return hFormat diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index fecf764e..7ed048a7 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -41,7 +41,7 @@ from PyQt5.QtWidgets import ( ) from novelwriter import CONFIG -from novelwriter.constants import nwConst, nwHeadingFormats +from novelwriter.constants import nwConst, nwHeadFmt from novelwriter.core.buildsettings import BuildSettings, FilterMode from novelwriter.extensions.switch import NSwitch from novelwriter.extensions.switchbox import NSwitchBox @@ -706,13 +706,13 @@ class GuiBuildHeadingsTab(QWidget): self.aInsScNum = self.menuInsert.addAction(self.tr("Scene Number (In Chapter)")) self.aInsScAbs = self.menuInsert.addAction(self.tr("Scene Number (Absolute)")) - self.aInsTitle.triggered.connect(lambda: self._insertIntoForm(nwHeadingFormats.TITLE)) - self.aInsChNum.triggered.connect(lambda: self._insertIntoForm(nwHeadingFormats.CH_NUM)) - self.aInsChWord.triggered.connect(lambda: self._insertIntoForm(nwHeadingFormats.CH_WORD)) - self.aInsChRomU.triggered.connect(lambda: self._insertIntoForm(nwHeadingFormats.CH_ROMU)) - self.aInsChRomL.triggered.connect(lambda: self._insertIntoForm(nwHeadingFormats.CH_ROML)) - self.aInsScNum.triggered.connect(lambda: self._insertIntoForm(nwHeadingFormats.SC_NUM)) - self.aInsScAbs.triggered.connect(lambda: self._insertIntoForm(nwHeadingFormats.SC_ABS)) + self.aInsTitle.triggered.connect(lambda: self._insertIntoForm(nwHeadFmt.TITLE)) + self.aInsChNum.triggered.connect(lambda: self._insertIntoForm(nwHeadFmt.CH_NUM)) + self.aInsChWord.triggered.connect(lambda: self._insertIntoForm(nwHeadFmt.CH_WORD)) + self.aInsChRomU.triggered.connect(lambda: self._insertIntoForm(nwHeadFmt.CH_ROMU)) + self.aInsChRomL.triggered.connect(lambda: self._insertIntoForm(nwHeadFmt.CH_ROML)) + self.aInsScNum.triggered.connect(lambda: self._insertIntoForm(nwHeadFmt.SC_NUM)) + self.aInsScAbs.triggered.connect(lambda: self._insertIntoForm(nwHeadFmt.SC_ABS)) self.btnInsert = QPushButton(self.tr("Insert")) self.btnInsert.setMenu(self.menuInsert) @@ -845,31 +845,17 @@ class GuiHeadingSyntax(QSyntaxHighlighter): def __init__(self, document: QTextDocument, mainTheme: GuiTheme): super().__init__(document) - - self._valid = [ - nwHeadingFormats.TITLE.lower(), - nwHeadingFormats.CH_NUM.lower(), - nwHeadingFormats.CH_WORD.lower(), - nwHeadingFormats.CH_ROMU.lower(), - nwHeadingFormats.CH_ROML.lower(), - nwHeadingFormats.SC_NUM.lower(), - nwHeadingFormats.SC_ABS.lower(), - ] - self._fmtSymbol = QTextCharFormat() self._fmtSymbol.setForeground(QColor(*mainTheme.colHead)) - self._fmtFormat = QTextCharFormat() self._fmtFormat.setForeground(QColor(*mainTheme.colEmph)) - return def highlightBlock(self, text: str): """Add syntax highlighting to the text block. """ - check = text.lower() - for heading in self._valid: - pos = check.find(heading) + for heading in nwHeadFmt.ALL: + pos = text.find(heading) if pos >= 0: chars = len(heading) self.setFormat(pos, chars, self._fmtSymbol) diff --git a/tests/test_core/test_core_docbuild.py b/tests/test_core/test_core_docbuild.py index 5b56f317..dde00264 100644 --- a/tests/test_core/test_core_docbuild.py +++ b/tests/test_core/test_core_docbuild.py @@ -30,28 +30,6 @@ from tools import ODT_IGNORE, cmpFiles from novelwriter.core.project import NWProject from novelwriter.core.docbuild import NWBuildDocument -# BUILD_CONF = { -# "format.fmtTitle": "Title: %title%", -# "format.fmtChapter": "Chapter: %title%", -# "format.fmtUnnumbered": "%title%", -# "format.fmtScene": "Scene: %title%", -# "format.fmtSection": "Section: %title%", -# "format.buildLang": "en_GB", -# "format.hideScene": False, -# "format.hideSection": False, -# "format.textFont": "Arial", -# "format.textSize": 12, -# "format.lineHeight": 1.5, -# "format.justifyText": True, -# "format.noStyling": False, -# "format.replaceUCode": False, -# "filter.includeSynopsis": True, -# "filter.includeComments": True, -# "filter.includeKeywords": True, -# "filter.includeBody": True, -# "process.replaceTabs": True, -# } - BUILD_CONF = { "name": "Test Build", "uuid": "f8796eee-e234-4e8a-8355-b2709177e53c", diff --git a/tests/test_core/test_core_tokenizer.py b/tests/test_core/test_core_tokenizer.py index 053daf90..39bbc8fc 100644 --- a/tests/test_core/test_core_tokenizer.py +++ b/tests/test_core/test_core_tokenizer.py @@ -20,10 +20,10 @@ along with this program. If not, see . """ import pytest -from novelwriter.constants import nwHeadingFormats from tools import C, buildTestProject, readFile +from novelwriter.constants import nwHeadFmt from novelwriter.core.project import NWProject from novelwriter.core.tokenizer import Tokenizer, stripEscape @@ -41,11 +41,11 @@ def testCoreToken_Setters(mockGUI): theToken = BareTokenizer(theProject) # Verify defaults - assert theToken._fmtTitle == "%title%" - assert theToken._fmtChapter == "%title%" - assert theToken._fmtUnNum == "%title%" - assert theToken._fmtScene == "%title%" - assert theToken._fmtSection == "%title%" + assert theToken._fmtTitle == nwHeadFmt.TITLE + assert theToken._fmtChapter == nwHeadFmt.TITLE + assert theToken._fmtUnNum == nwHeadFmt.TITLE + assert theToken._fmtScene == nwHeadFmt.TITLE + assert theToken._fmtSection == nwHeadFmt.TITLE assert theToken._textFont == "Serif" assert theToken._textSize == 11 assert theToken._textFixed is False @@ -68,11 +68,11 @@ def testCoreToken_Setters(mockGUI): assert theToken._doKeywords is False # Set new values - theToken.setTitleFormat("T: %title%") - theToken.setChapterFormat("C: %title%") - theToken.setUnNumberedFormat("U: %title%") - theToken.setSceneFormat("S: %title%", True) - theToken.setSectionFormat("X: %title%", True) + theToken.setTitleFormat(f"T: {nwHeadFmt.TITLE}") + theToken.setChapterFormat(f"C: {nwHeadFmt.TITLE}") + theToken.setUnNumberedFormat(f"U: {nwHeadFmt.TITLE}") + theToken.setSceneFormat(f"S: {nwHeadFmt.TITLE}", True) + theToken.setSectionFormat(f"X: {nwHeadFmt.TITLE}", True) theToken.setFont("Monospace", 10, True) theToken.setLineHeight(2.0) theToken.setBlockIndent(6.0) @@ -91,11 +91,11 @@ def testCoreToken_Setters(mockGUI): theToken.setKeywords(True) # Check new values - assert theToken._fmtTitle == "T: %title%" - assert theToken._fmtChapter == "C: %title%" - assert theToken._fmtUnNum == "U: %title%" - assert theToken._fmtScene == "S: %title%" - assert theToken._fmtSection == "X: %title%" + assert theToken._fmtTitle == f"T: {nwHeadFmt.TITLE}" + assert theToken._fmtChapter == f"C: {nwHeadFmt.TITLE}" + assert theToken._fmtUnNum == f"U: {nwHeadFmt.TITLE}" + assert theToken._fmtScene == f"S: {nwHeadFmt.TITLE}" + assert theToken._fmtSection == f"X: {nwHeadFmt.TITLE}" assert theToken._textFont == "Monospace" assert theToken._textSize == 10 assert theToken._textFixed is True @@ -918,7 +918,7 @@ def testCoreToken_ProcessHeaders(mockGUI): # H1: Title, First Page assert theToken._isFirst is True theToken._theText = "# Part One\n" - theToken.setTitleFormat(f"T: {nwHeadingFormats.TITLE}") + theToken.setTitleFormat(f"T: {nwHeadFmt.TITLE}") theToken.tokenizeText() theToken.doHeaders() assert theToken._theTokens == [ @@ -929,7 +929,7 @@ def testCoreToken_ProcessHeaders(mockGUI): # H1: Title, Not First Page assert theToken._isFirst is False theToken._theText = "# Part One\n" - theToken.setTitleFormat(f"T: {nwHeadingFormats.TITLE}") + theToken.setTitleFormat(f"T: {nwHeadFmt.TITLE}") theToken.tokenizeText() theToken.doHeaders() assert theToken._theTokens == [ @@ -942,7 +942,7 @@ def testCoreToken_ProcessHeaders(mockGUI): # H2: Chapter theToken._theText = "## Chapter One\n" - theToken.setChapterFormat(f"C: {nwHeadingFormats.TITLE}") + theToken.setChapterFormat(f"C: {nwHeadFmt.TITLE}") theToken.tokenizeText() theToken.doHeaders() assert theToken._theTokens == [ @@ -952,7 +952,7 @@ def testCoreToken_ProcessHeaders(mockGUI): # H2: Unnumbered Chapter theToken._theText = "##! Prologue\n" - theToken.setUnNumberedFormat(f"U: {nwHeadingFormats.TITLE}") + theToken.setUnNumberedFormat(f"U: {nwHeadFmt.TITLE}") theToken.tokenizeText() theToken.doHeaders() assert theToken._theTokens == [ @@ -962,7 +962,7 @@ def testCoreToken_ProcessHeaders(mockGUI): # H2: Chapter Word Number theToken._theText = "## Chapter\n" - theToken.setChapterFormat(f"Chapter {nwHeadingFormats.CH_WORD}") + theToken.setChapterFormat(f"Chapter {nwHeadFmt.CH_WORD}") theToken._hFormatter._chCount = 0 theToken.tokenizeText() theToken.doHeaders() @@ -973,7 +973,7 @@ def testCoreToken_ProcessHeaders(mockGUI): # H2: Chapter Roman Number Upper Case theToken._theText = "## Chapter\n" - theToken.setChapterFormat(f"Chapter {nwHeadingFormats.CH_ROMU}") + theToken.setChapterFormat(f"Chapter {nwHeadFmt.CH_ROMU}") theToken.tokenizeText() theToken.doHeaders() assert theToken._theTokens == [ @@ -983,7 +983,7 @@ def testCoreToken_ProcessHeaders(mockGUI): # H2: Chapter Roman Number Lower Case theToken._theText = "## Chapter\n" - theToken.setChapterFormat(f"Chapter {nwHeadingFormats.CH_ROML}") + theToken.setChapterFormat(f"Chapter {nwHeadFmt.CH_ROML}") theToken.tokenizeText() theToken.doHeaders() assert theToken._theTokens == [ @@ -996,7 +996,7 @@ def testCoreToken_ProcessHeaders(mockGUI): # H3: Scene w/Title theToken._theText = "### Scene One\n" - theToken.setSceneFormat(f"S: {nwHeadingFormats.TITLE}", False) + theToken.setSceneFormat(f"S: {nwHeadFmt.TITLE}", False) theToken.tokenizeText() theToken.doHeaders() assert theToken._theTokens == [ @@ -1060,7 +1060,7 @@ def testCoreToken_ProcessHeaders(mockGUI): # H3: Scene w/Absolute Number theToken._theText = "### A Scene\n" - theToken.setSceneFormat(f"Scene {nwHeadingFormats.SC_ABS}", False) + theToken.setSceneFormat(f"Scene {nwHeadFmt.SC_ABS}", False) theToken._hFormatter._scAbsCount = 0 theToken._hFormatter._scChCount = 0 theToken.tokenizeText() @@ -1072,7 +1072,7 @@ def testCoreToken_ProcessHeaders(mockGUI): # H3: Scene w/Chapter Number theToken._theText = "### A Scene\n" - theToken.setSceneFormat(f"Scene {nwHeadingFormats.CH_NUM}.{nwHeadingFormats.SC_NUM}", False) + theToken.setSceneFormat(f"Scene {nwHeadFmt.CH_NUM}.{nwHeadFmt.SC_NUM}", False) theToken._hFormatter._scAbsCount = 0 theToken._hFormatter._scChCount = 1 theToken.tokenizeText() @@ -1107,7 +1107,7 @@ def testCoreToken_ProcessHeaders(mockGUI): # H4: Section w/Format theToken._theText = "#### A Section\n" - theToken.setSectionFormat(f"X: {nwHeadingFormats.TITLE}", False) + theToken.setSectionFormat(f"X: {nwHeadFmt.TITLE}", False) theToken.tokenizeText() theToken.doHeaders() assert theToken._theTokens == [ From ad6b517c1d3efa262ec268511d6e023279c0cb88 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 24 May 2023 22:38:47 +0200 Subject: [PATCH 37/74] Rename manuscript build tool file and add build dialog --- novelwriter/core/options.py | 2 +- novelwriter/core/tokenizer.py | 3 +- novelwriter/core/toodt.py | 8 ++- novelwriter/guimain.py | 8 +-- .../tools/{manusbuild.py => manuscript.py} | 69 +++++++++++++++---- novelwriter/tools/manussettings.py | 2 +- 6 files changed, 69 insertions(+), 23 deletions(-) rename novelwriter/tools/{manusbuild.py => manuscript.py} (81%) diff --git a/novelwriter/core/options.py b/novelwriter/core/options.py index 8637d937..e0ce2a2c 100644 --- a/novelwriter/core/options.py +++ b/novelwriter/core/options.py @@ -63,7 +63,7 @@ VALID_MAP = { "GuiBuildSettings": { "winWidth", "winHeight", "treeWidth", "filterWidth" }, - "GuiBuildManuscript": { + "GuiManuscript": { "winWidth", "winHeight", "optsWidth", "viewWidth" }, } diff --git a/novelwriter/core/tokenizer.py b/novelwriter/core/tokenizer.py index fe5bb06d..a978cff4 100644 --- a/novelwriter/core/tokenizer.py +++ b/novelwriter/core/tokenizer.py @@ -4,7 +4,8 @@ novelWriter – Text Tokenizer Split novelWriter plain text into its elements File History: -Created: 2019-05-05 [0.0.1] +Created: 2019-05-05 [0.0.1] Tokenizer +Created: 2023-05-23 [2.1b1] HeadingFormatter This file is a part of novelWriter Copyright 2018–2023, Veronica Berglyd Olsen diff --git a/novelwriter/core/toodt.py b/novelwriter/core/toodt.py index 4251d066..4892d435 100644 --- a/novelwriter/core/toodt.py +++ b/novelwriter/core/toodt.py @@ -4,7 +4,10 @@ novelWriter – ODT Text Converter Extends the Tokenizer class to generate ODT and FODT files File History: -Created: 2021-01-26 [1.2b1] +Created: 2021-01-26 [1.2b1] ToOdt +Created: 2021-01-27 [1.2b1] ODTParagraphStyle +Created: 2021-01-27 [1.2b1] ODTTextStyle +Created: 2021-08-14 [1.5b1] XMLParagraph This file is a part of novelWriter Copyright 2018–2023, Veronica Berglyd Olsen @@ -1439,7 +1442,8 @@ class XMLParagraph: def checkError(self): """Check that the number of characters written matches the - number of characters received.""" + number of characters received. + """ errMsg = "" nMissed = len(self._rawTxt) - self._chrPos if nMissed != 0: diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 684b61d6..44bfaebb 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -57,7 +57,7 @@ from novelwriter.dialogs.projdetails import GuiProjectDetails from novelwriter.dialogs.projsettings import GuiProjectSettings from novelwriter.tools.build import GuiBuildNovel from novelwriter.tools.lipsum import GuiLipsum -from novelwriter.tools.manusbuild import GuiBuildManuscript +from novelwriter.tools.manuscript import GuiManuscript from novelwriter.tools.projwizard import GuiProjectWizard from novelwriter.tools.writingstats import GuiWritingStats from novelwriter.core.project import NWProject @@ -1020,10 +1020,10 @@ class GuiMain(QMainWindow): logger.error("No project open") return False - dlgBuild = getGuiItem("GuiBuildManuscript") + dlgBuild = getGuiItem("GuiManuscript") if dlgBuild is None: - dlgBuild = GuiBuildManuscript(self) - assert isinstance(dlgBuild, GuiBuildManuscript) + dlgBuild = GuiManuscript(self) + assert isinstance(dlgBuild, GuiManuscript) dlgBuild.setModal(False) dlgBuild.show() diff --git a/novelwriter/tools/manusbuild.py b/novelwriter/tools/manuscript.py similarity index 81% rename from novelwriter/tools/manusbuild.py rename to novelwriter/tools/manuscript.py index 986f1d26..e638776a 100644 --- a/novelwriter/tools/manusbuild.py +++ b/novelwriter/tools/manuscript.py @@ -4,7 +4,9 @@ novelWriter – GUI Build Manuscript GUI classes for the Manuscript Build Tool File History: -Created: 2023-05-13 [2.1b1] +Created: 2023-05-13 [2.1b1] GuiManuscript +Created: 2023-05-13 [2.1b1] GuiManuscriptPreview +Created: 2023-05-24 [2.1b1] GuiManuscriptBuild This file is a part of novelWriter Copyright 2018–2023, Veronica Berglyd Olsen @@ -44,11 +46,14 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) -class GuiBuildManuscript(QDialog): +class GuiManuscript(QDialog): def __init__(self, mainGui: GuiMain): super().__init__(parent=mainGui) + logger.debug("Initialising GuiManuscript ...") + self.setObjectName("GuiManuscript") + self.mainGui = mainGui self.mainTheme = mainGui.mainTheme self.theProject = mainGui.theProject @@ -65,8 +70,8 @@ class GuiBuildManuscript(QDialog): pOptions = self.theProject.options self.resize( - CONFIG.pxInt(pOptions.getInt("GuiBuildManuscript", "winWidth", wWin)), - CONFIG.pxInt(pOptions.getInt("GuiBuildManuscript", "winHeight", hWin)) + CONFIG.pxInt(pOptions.getInt("GuiManuscript", "winWidth", wWin)), + CONFIG.pxInt(pOptions.getInt("GuiManuscript", "winHeight", hWin)) ) # Build Controls @@ -110,6 +115,7 @@ class GuiBuildManuscript(QDialog): self.btnSave.setMenu(self.menuSave) self.btnClose = QPushButton(self.tr("Close")) + self.btnClose.clicked.connect(self._doClose) # Assemble GUI # ============ @@ -139,8 +145,8 @@ class GuiBuildManuscript(QDialog): self.mainSplit.addWidget(self.optsWidget) self.mainSplit.addWidget(self.manPreview) self.mainSplit.setSizes([ - CONFIG.pxInt(pOptions.getInt("GuiBuildManuscript", "optsWidth", wWin//3)), - CONFIG.pxInt(pOptions.getInt("GuiBuildManuscript", "viewWidth", 2*wWin//3)), + CONFIG.pxInt(pOptions.getInt("GuiManuscript", "optsWidth", wWin//3)), + CONFIG.pxInt(pOptions.getInt("GuiManuscript", "viewWidth", 2*wWin//3)), ]) self.outerBox = QVBoxLayout() @@ -148,10 +154,12 @@ class GuiBuildManuscript(QDialog): self.setLayout(self.outerBox) + logger.debug("GuiManuscript initialisation complete") + return def loadContent(self): - """ + """Load dialog content from project data. """ self._builds.loadCollection() self._updateBuildsList() @@ -164,7 +172,7 @@ class GuiBuildManuscript(QDialog): def closeEvent(self, event): """Capture the user closing the window so we can save settings. """ - self._saveSettings() + self._beforeClose() event.accept() return @@ -201,14 +209,36 @@ class GuiBuildManuscript(QDialog): self._updateBuildItem(build) return + @pyqtSlot() + def _doClose(self): + """The close button has been clicked. + """ + self._beforeClose() + self.close() + return + ## # Internal Functions ## + def _saveManuscript(self, outFormat: int): + """Save the manuscript file or files. + """ + return + + def _beforeClose(self): + """List of things to do before closing. + """ + self._saveSettings() + for qWidget in qApp.topLevelWidgets(): + if qWidget.objectName() == "GuiBuildSettings": + qWidget.close() + return + def _saveSettings(self): """Save the various user settings. """ - logger.debug("Saving GuiBuildManuscript settings") + logger.debug("Saving GuiManuscript settings") winWidth = CONFIG.rpxInt(self.width()) winHeight = CONFIG.rpxInt(self.height()) @@ -218,10 +248,10 @@ class GuiBuildManuscript(QDialog): viewWidth = mainSplit[1] pOptions = self.theProject.options - pOptions.setValue("GuiBuildManuscript", "winWidth", winWidth) - pOptions.setValue("GuiBuildManuscript", "winHeight", winHeight) - pOptions.setValue("GuiBuildManuscript", "optsWidth", optsWidth) - pOptions.setValue("GuiBuildManuscript", "viewWidth", viewWidth) + pOptions.setValue("GuiManuscript", "winWidth", winWidth) + pOptions.setValue("GuiManuscript", "winHeight", winHeight) + pOptions.setValue("GuiManuscript", "optsWidth", optsWidth) + pOptions.setValue("GuiManuscript", "viewWidth", viewWidth) pOptions.saveSettings() return @@ -260,7 +290,7 @@ class GuiBuildManuscript(QDialog): self._updateBuildsList() return -# END Class GuiBuildManuscript +# END Class GuiManuscript class GuiManuscriptPreview(QTextBrowser): @@ -275,3 +305,14 @@ class GuiManuscriptPreview(QTextBrowser): return # END Class GuiManuscriptPreview + + +class GuiManuscriptBuild(QDialog): + + def __init__(self, parent): + super().__init__(parent=parent) + self._manusGui = parent + + return + +# END Class GuiManuscriptBuild diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index 7ed048a7..777ffa13 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -238,7 +238,7 @@ class GuiBuildSettings(QDialog): """ if self._build.changed: return self.mainGui.askQuestion( - self.tr("Close"), + self.tr("Build Settings"), self.tr("There are unsaved changes. Close anyway?") ) return True From 7c9bff71f0d5ef4547f789cf14fe756ce6e9117c Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 24 May 2023 22:39:24 +0200 Subject: [PATCH 38/74] Make sure main logger only creates a single stream handle --- novelwriter/__init__.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/novelwriter/__init__.py b/novelwriter/__init__.py index 8597a55c..ccc9a333 100644 --- a/novelwriter/__init__.py +++ b/novelwriter/__init__.py @@ -155,13 +155,14 @@ def main(sysArgs=None): elif inOpt == "--testmode": testMode = True - # Set Logging - cHandle = logging.StreamHandler() - cHandle.setFormatter(logging.Formatter(fmt=logFormat, style="{")) - + # Setup Logging pkgLogger = logging.getLogger(__package__) - pkgLogger.addHandler(cHandle) pkgLogger.setLevel(logLevel) + if len(pkgLogger.handlers) == 0: + # Make sure we only create one logger (mostly an issue with tests) + cHandle = logging.StreamHandler() + cHandle.setFormatter(logging.Formatter(fmt=logFormat, style="{")) + pkgLogger.addHandler(cHandle) logger.info("Starting novelWriter %s (%s) %s", __version__, __hexversion__, __date__) From 74f46f46335b87dcb34aa17fff132b51c25442e7 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Mon, 29 May 2023 22:22:18 +0200 Subject: [PATCH 39/74] Update broken test by merge from dev --- ...uildDocBuild_OpenDocument_Lorem_Ipsum.fodt | 86 +++++++++---------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/tests/reference/mBuildDocBuild_OpenDocument_Lorem_Ipsum.fodt b/tests/reference/mBuildDocBuild_OpenDocument_Lorem_Ipsum.fodt index ba183f64..aba1a301 100644 --- a/tests/reference/mBuildDocBuild_OpenDocument_Lorem_Ipsum.fodt +++ b/tests/reference/mBuildDocBuild_OpenDocument_Lorem_Ipsum.fodt @@ -1,96 +1,96 @@ - + - 2023-02-12T17:28:07 - novelWriter/2.0.5 + 2023-05-29T22:20:22 + novelWriter/2.0.7 lipsum.com - 38 - P0DT0H31M40S + 40 + P0DT0H31M45S Lorem Ipsum - 2023-02-12T17:28:07 + 2023-05-29T22:20:22 lipsum.com - + - - + + - + - - + + - + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + - + - + - + - + - + - + - + - + - + - + - + @@ -99,7 +99,7 @@ Lorem Ipsum / lipsum.com / 2 - + @@ -147,9 +147,9 @@ Ut et consequat enim, quis ornare nibh. In lectus neque, mollis et suscipit et, vestibulum vitae augue. Praesent id ante sit amet odio venenatis placerat a at erat. Sed sed metus sed nisi dictum varius. Integer tincidunt fermentum purus ac porta. Fusce porttitor non risus eget tristique. Donec augue nunc, maximus at fermentum vel, varius et neque. Ut sed consectetur mauris. Quisque ipsum enim, porttitor vitae imperdiet sit amet, tempor et mauris. Aliquam malesuada tincidunt lectus quis blandit. Sed commodo orci felis, quis ultrices tellus facilisis sed. Nunc vel varius est. Duis ullamcorper eu metus in pulvinar. Morbi at sapien dictum, rutrum mauris eget, interdum tellus. Why do we use it? Comment: Exctracted from the lipsum.com website. - It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout. - The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, as opposed to using 'Content here, content here', making it look like readable English. - Many desktop publishing packages and web page editors now use Lorem Ipsum as their default model text, and a search for 'lorem ipsum' will uncover many web sites still in their infancy. Various versions have evolved over the years, sometimes by accident, sometimes on purpose (injected humour and the like). + It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout. + The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, as opposed to using 'Content here, content here', making it look like readable English. + Many desktop publishing packages and web page editors now use Lorem Ipsum as their default model text, and a search for 'lorem ipsum' will uncover many web sites still in their infancy. Various versions have evolved over the years, sometimes by accident, sometimes on purpose (injected humour and the like). Chapter: Chapter Two Point of View: Bod Plot: Main From 704fffada3965981d6681827b86a72faad4278ff Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 31 May 2023 17:49:31 +0200 Subject: [PATCH 40/74] Move all code to set default text font into the Config class --- novelwriter/__init__.py | 5 +- novelwriter/config.py | 51 +++++++++++++++------ novelwriter/constants.py | 4 -- novelwriter/core/buildsettings.py | 4 +- novelwriter/dialogs/preferences.py | 3 +- novelwriter/gui/doceditor.py | 25 ++-------- novelwriter/gui/docviewer.py | 11 ++--- novelwriter/gui/theme.py | 8 ++-- novelwriter/tools/build.py | 3 -- novelwriter/tools/manussettings.py | 4 +- tests/conftest.py | 1 + tests/reference/baseConfig_novelwriter.conf | 4 +- tests/test_gui/test_gui_doceditor.py | 2 +- 13 files changed, 62 insertions(+), 63 deletions(-) diff --git a/novelwriter/__init__.py b/novelwriter/__init__.py index c85d949b..85ac0d62 100644 --- a/novelwriter/__init__.py +++ b/novelwriter/__init__.py @@ -241,8 +241,11 @@ def main(sysArgs=None): # Connect the exception handler before making the main GUI sys.excepthook = exceptionHandler - # Launch main GUI + # Run Config steps that require the QApplication CONFIG.initLocalisation(nwApp) + CONFIG.setTextFont(CONFIG.textFont, CONFIG.textSize) # Makes sure these are valid + + # Launch main GUI nwGUI = GuiMain() nwGUI.postLaunchTasks(cmdOpen) diff --git a/novelwriter/config.py b/novelwriter/config.py index 68f44ec4..bb4fa9dc 100644 --- a/novelwriter/config.py +++ b/novelwriter/config.py @@ -23,6 +23,8 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ +from __future__ import annotations + import sys import json import logging @@ -30,6 +32,7 @@ import logging from time import time from pathlib import Path +from PyQt5.QtGui import QFontDatabase from PyQt5.QtCore import ( QT_VERSION, QT_VERSION_STR, PYQT_VERSION, PYQT_VERSION_STR, QStandardPaths, QSysInfo, QLocale, QLibraryInfo, QTranslator @@ -120,7 +123,7 @@ class Config: self.askBeforeBackup = True # Flag for asking before running automatic backup # Text Editor Settings - self.textFont = None # Editor font + self.textFont = "" # Editor font self.textSize = 12 # Editor font size self.textWidth = 700 # Editor text width self.textMargin = 40 # Editor/viewer text margin @@ -335,40 +338,59 @@ class Config: logger.debug("Last path updated: %s" % self._lastPath) return - def setBackupPath(self, backupPath): + def setBackupPath(self, backupPath: Path | None): """Set the current backup path.""" self._backupPath = checkPath(backupPath, None) return + def setTextFont(self, family: str | None, pointSize: int = 12): + """Set the text font if it exists. If it doesn't, or is None, + set to default font. + """ + fontDB = QFontDatabase() + fontFam = fontDB.families() + self.textSize = pointSize + if family is None or family not in fontFam: + logger.warning("Unknown font '%s'", family) + if self.osWindows and "Arial" in fontFam: + self.textFont = "Arial" + elif self.osDarwin and "Courier" in fontFam: + self.textFont = "Courier" + else: + self.textFont = fontDB.systemFont(QFontDatabase.GeneralFont).family() + else: + self.textFont = family + return + ## # Methods ## - def pxInt(self, theSize): + def pxInt(self, value: int) -> int: """Used to scale fixed gui sizes by the screen scale factor. This function returns an int, which is always rounded down. """ - return int(theSize*self.guiScale) + return int(value*self.guiScale) - def rpxInt(self, theSize): + def rpxInt(self, value: int) -> int: """Used to un-scale fixed gui sizes by the screen scale factor. This function returns an int, which is always rounded down. """ - return int(theSize/self.guiScale) + return int(value/self.guiScale) - def dataPath(self, target=None): + def dataPath(self, target: str | None = None) -> Path: """Return a path in the data folder.""" if isinstance(target, str): return self._dataPath / target return self._dataPath - def assetPath(self, target=None): + def assetPath(self, target: str | None = None) -> Path: """Return a path in the assets folder.""" if isinstance(target, str): return self._appPath / "assets" / target return self._appPath / "assets" - def lastPath(self): + def lastPath(self) -> Path: """Return the last path used by the user, but ensure it exists. """ if isinstance(self._lastPath, Path): @@ -376,14 +398,14 @@ class Config: return self._lastPath return self._homePath - def backupPath(self): + def backupPath(self) -> Path | None: """Return the backup path.""" if isinstance(self._backupPath, Path): if self._backupPath.is_dir(): return self._backupPath return None - def errorText(self): + def errorText(self) -> str: """Compile and return error messages from the initialisation of the Config class, and clear the error buffer. """ @@ -392,7 +414,7 @@ class Config: self._errData = [] return errMessage - def listLanguages(self, lngSet): + def listLanguages(self, lngSet: int) -> list[tuple[str, str]]: """List localisation files in the i18n folder. The default GUI language is British English (en_GB). """ @@ -423,7 +445,7 @@ class Config: # Config Actions ## - def initConfig(self, confPath=None, dataPath=None): + def initConfig(self, confPath: str | Path | None = None, dataPath: str | Path | None = None): """Initialise the config class. The manual setting of confPath and dataPath is mainly intended for the test suite. """ @@ -449,9 +471,10 @@ class Config: # Also create the syntax, themes and icons folders if possible if self._dataPath.is_dir(): + (self._dataPath / "cache").mkdir(exist_ok=True) + (self._dataPath / "icons").mkdir(exist_ok=True) (self._dataPath / "syntax").mkdir(exist_ok=True) (self._dataPath / "themes").mkdir(exist_ok=True) - (self._dataPath / "icons").mkdir(exist_ok=True) # Check if config file exists, and load it. If not, we save defaults if (self._confPath / nwFiles.CONF_FILE).is_file(): diff --git a/novelwriter/constants.py b/novelwriter/constants.py index e0d6e053..46e1c1d6 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -23,7 +23,6 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -from PyQt5.QtGui import QFontDatabase from PyQt5.QtCore import QCoreApplication, QT_TRANSLATE_NOOP from novelwriter.enum import nwItemClass, nwItemLayout, nwOutline @@ -54,9 +53,6 @@ class nwConst: URL_HELP = "https://github.com/vkbo/novelWriter/discussions" URL_RELEASE = "https://github.com/vkbo/novelWriter/releases/latest" - # System Values - SYSTEM_FONT = QFontDatabase.systemFont(QFontDatabase.GeneralFont).family() - # END Class nwConst diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index 845634d6..cc4b3df1 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -36,7 +36,7 @@ from pathlib import Path from PyQt5.QtCore import QT_TRANSLATE_NOOP from novelwriter.common import checkUuid, isHandle, jsonEncode -from novelwriter.constants import nwConst, nwFiles, nwHeadFmt +from novelwriter.constants import nwFiles, nwHeadFmt from novelwriter.core.item import NWItem from novelwriter.core.project import NWProject from novelwriter.error import logException @@ -65,7 +65,7 @@ SETTINGS_TEMPLATE = { "text.includeBodyText": (bool, True), "text.addNoteHeadings": (bool, True), "format.buildLang": (str, "en_GB"), - "format.textFont": (str, nwConst.SYSTEM_FONT), + "format.textFont": (str, ""), "format.textSize": (int, 12), "format.lineHeight": (float, 1.15, 0.75, 3.0), "format.justifyText": (bool, False), diff --git a/novelwriter/dialogs/preferences.py b/novelwriter/dialogs/preferences.py index 21fd101f..6a685eaf 100644 --- a/novelwriter/dialogs/preferences.py +++ b/novelwriter/dialogs/preferences.py @@ -613,8 +613,7 @@ class GuiPreferencesDocuments(QWidget): """Save the values set for this tab. """ # Text Style - CONFIG.textFont = self.textFont.text() - CONFIG.textSize = self.textSize.value() + CONFIG.setTextFont(self.textFont.text(), self.textSize.value()) # Text Flow CONFIG.textWidth = self.textWidth.value() diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index c7681759..2425d6be 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -276,31 +276,16 @@ class GuiDocEditor(QTextEdit): self.setDictionaries() # Set font - theFont = QFont() - qDoc = self.document() - if CONFIG.textFont is None: - # If none is defined, set a default font - theFont = QFont() - if CONFIG.osWindows and "Arial" in self.mainTheme.guiFontDB.families(): - theFont.setFamily("Arial") - theFont.setPointSize(12) - elif CONFIG.osDarwin and "Courier" in self.mainTheme.guiFontDB.families(): - theFont.setFamily("Courier") - theFont.setPointSize(12) - else: - theFont = qDoc.defaultFont() - - CONFIG.textFont = theFont.family() - CONFIG.textSize = theFont.pointSize() - - theFont.setFamily(CONFIG.textFont) - theFont.setPointSize(CONFIG.textSize) - self.setFont(theFont) + textFont = QFont() + textFont.setFamily(CONFIG.textFont) + textFont.setPointSize(CONFIG.textSize) + self.setFont(textFont) # Set default text margins # Due to cursor visibility, a part of the margin must be # allocated to the document itself. See issue #1112. cW = self.cursorWidth() + qDoc = self.document() qDoc.setDocumentMargin(cW) self._vpMargin = max(CONFIG.getTextMargin() - cW, 0) self.setViewportMargins(self._vpMargin, self._vpMargin, self._vpMargin, self._vpMargin) diff --git a/novelwriter/gui/docviewer.py b/novelwriter/gui/docviewer.py index 7cb8be4d..bae5c39a 100644 --- a/novelwriter/gui/docviewer.py +++ b/novelwriter/gui/docviewer.py @@ -114,13 +114,10 @@ class GuiDocViewer(QTextBrowser): self._makeStyleSheet() # Set Font - theFont = QFont() - if CONFIG.textFont is None: - # If none is defined, set the default back to config - CONFIG.textFont = self.document().defaultFont().family() - theFont.setFamily(CONFIG.textFont) - theFont.setPointSize(CONFIG.textSize) - self.setFont(theFont) + textFont = QFont() + textFont.setFamily(CONFIG.textFont) + textFont.setPointSize(CONFIG.textSize) + self.setFont(textFont) # Set the widget colours to match syntax theme mainPalette = self.palette() diff --git a/novelwriter/gui/theme.py b/novelwriter/gui/theme.py index 0d61423a..f1c3f0a4 100644 --- a/novelwriter/gui/theme.py +++ b/novelwriter/gui/theme.py @@ -107,7 +107,6 @@ class GuiTheme: # =========== # Init GUI Font - self.guiFontDB = QFontDatabase() self._setGuiFont() # Load Themes @@ -366,13 +365,14 @@ class GuiTheme: """Update the GUI's font style from settings. """ theFont = QFont() - if CONFIG.guiFont not in self.guiFontDB.families(): - if CONFIG.osWindows and "Arial" in self.guiFontDB.families(): + fontDB = QFontDatabase() + if CONFIG.guiFont not in fontDB.families(): + if CONFIG.osWindows and "Arial" in fontDB.families(): # On Windows we default to Arial if possible theFont.setFamily("Arial") theFont.setPointSize(10) else: - theFont = self.guiFontDB.systemFont(QFontDatabase.GeneralFont) + theFont = fontDB.systemFont(QFontDatabase.GeneralFont) CONFIG.guiFont = theFont.family() CONFIG.guiFontSize = theFont.pointSize() else: diff --git a/novelwriter/tools/build.py b/novelwriter/tools/build.py index 9fe56440..c013c7b8 100644 --- a/novelwriter/tools/build.py +++ b/novelwriter/tools/build.py @@ -1258,9 +1258,6 @@ class GuiBuildNovelDocView(QTextBrowser): )) theFont = QFont() - if CONFIG.textFont is None: - # If none is defined, set the default back to config - CONFIG.textFont = self.document().defaultFont().family() theFont.setFamily(CONFIG.textFont) theFont.setPointSize(CONFIG.textSize) self.setFont(theFont) diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index 777ffa13..5f2a904e 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -41,7 +41,7 @@ from PyQt5.QtWidgets import ( ) from novelwriter import CONFIG -from novelwriter.constants import nwConst, nwHeadFmt +from novelwriter.constants import nwHeadFmt from novelwriter.core.buildsettings import BuildSettings, FilterMode from novelwriter.extensions.switch import NSwitch from novelwriter.extensions.switchbox import NSwitchBox @@ -1032,8 +1032,6 @@ class GuiBuildFormatTab(QWidget): textFont = self._build.getStr("format.textFont") if not textFont: textFont = str(CONFIG.textFont) - if not textFont: - textFont = nwConst.SYSTEM_FONT self.textFont.setText(textFont) self.textSize.setValue(self._build.getInt("format.textSize")) diff --git a/tests/conftest.py b/tests/conftest.py index 9435eee6..d333a1b9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -49,6 +49,7 @@ def resetConfigVars(): """ CONFIG.setLastPath(_TMP_ROOT) CONFIG.setBackupPath(_TMP_ROOT) + CONFIG.setTextFont(None) CONFIG._homePath = _TMP_ROOT CONFIG.guiLocale = "en_GB" return diff --git a/tests/reference/baseConfig_novelwriter.conf b/tests/reference/baseConfig_novelwriter.conf index 185fbd50..fa12d483 100644 --- a/tests/reference/baseConfig_novelwriter.conf +++ b/tests/reference/baseConfig_novelwriter.conf @@ -1,5 +1,5 @@ [Meta] -timestamp = 2022-11-11 12:48:18 +timestamp = 2023-05-31 10:52:21 [Main] theme = default @@ -29,7 +29,7 @@ backuponclose = False askbeforebackup = True [Editor] -textfont = None +textfont = textsize = 12 width = 700 margin = 40 diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index cc386515..34f9dfd1 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -56,7 +56,7 @@ def testGuiEditor_Init(qtbot, nwGUI, projPath, ipsumText, mockRnd): assert nwGUI.docEditor._typPadChar == nwUnicode.U_NBSP # Check that editor handles settings - CONFIG.textFont = None + CONFIG.textFont = "" CONFIG.doJustify = True CONFIG.showTabsNSpaces = True CONFIG.showLineEndings = True From 510b6ff6d2bdbc13c456ebaabafd836197d4ce0e Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 31 May 2023 18:10:41 +0200 Subject: [PATCH 41/74] Add simple build preview --- novelwriter/core/docbuild.py | 47 ++++++++----- novelwriter/tools/manusbuild.py | 42 ++++++++++++ novelwriter/tools/manuscript.py | 95 ++++++++++++++++++++++----- novelwriter/tools/manussettings.py | 2 +- sample/nwProject.nwx | 6 +- tests/test_core/test_core_docbuild.py | 8 +-- 6 files changed, 159 insertions(+), 41 deletions(-) create mode 100644 novelwriter/tools/manusbuild.py diff --git a/novelwriter/core/docbuild.py b/novelwriter/core/docbuild.py index 1ab1a09e..b946d20f 100644 --- a/novelwriter/core/docbuild.py +++ b/novelwriter/core/docbuild.py @@ -33,7 +33,6 @@ from PyQt5.QtGui import QFont, QFontInfo from novelwriter import CONFIG from novelwriter.error import formatException -from novelwriter.constants import nwConst from novelwriter.core.tomd import ToMarkdown from novelwriter.core.toodt import ToOdt from novelwriter.core.tohtml import ToHtml @@ -46,13 +45,14 @@ logger = logging.getLogger(__name__) class NWBuildDocument: - def __init__(self, project: NWProject, build: BuildSettings): + __slots__ = ("_project", "_build", "_queue", "_error", "_cache") + def __init__(self, project: NWProject, build: BuildSettings): self._project = project self._build = build self._queue = [] self._error = None - + self._cache = None return ## @@ -64,7 +64,14 @@ class NWBuildDocument: return self._error @property - def buildLength(self) -> int: + def lastBuild(self) -> Tokenizer | None: + return self._cache + + ## + # Special Methods + ## + + def __len__(self) -> int: return len(self._queue) ## @@ -89,7 +96,7 @@ class NWBuildDocument: self._queue.append(item.itemHandle) return - def iterBuildOpenDocument(self, savePath: Path, isFlat: bool) -> Iterable[tuple[int, bool]]: + def iterBuildOpenDocument(self, path: Path, isFlat: bool) -> Iterable[tuple[int, bool]]: """Build an Open Document file. """ makeOdt = ToOdt(self._project, isFlat=isFlat) @@ -102,18 +109,21 @@ class NWBuildDocument: makeOdt.closeDocument() self._error = None + self._cache = makeOdt + try: if isFlat: - makeOdt.saveFlatXML(savePath) + makeOdt.saveFlatXML(path) else: - makeOdt.saveOpenDocText(savePath) + makeOdt.saveOpenDocText(path) except Exception as exc: self._error = formatException(exc) return - def iterBuildHTML(self, savePath: Path) -> Iterable[tuple[int, bool]]: - """Build an HTML file. + def iterBuildHTML(self, path: Path | None) -> Iterable[tuple[int, bool]]: + """Build an HTML file. If path is None, no file is saved. This + is used for generating build previews. """ makeHtml = ToHtml(self._project) self._setupBuild(makeHtml) @@ -125,14 +135,17 @@ class NWBuildDocument: yield i, self._doBuild(makeHtml, tHandle) self._error = None - try: - makeHtml.saveHTML5(savePath) - except Exception as exc: - self._error = formatException(exc) + self._cache = makeHtml + + if isinstance(path, Path): + try: + makeHtml.saveHTML5(path) + except Exception as exc: + self._error = formatException(exc) return - def iterBuildMarkdown(self, savePath: Path, extendedMd: bool) -> Iterable[tuple[int, bool]]: + def iterBuildMarkdown(self, path: Path, extendedMd: bool) -> Iterable[tuple[int, bool]]: """Build a Markdown file. """ makeMd = ToMarkdown(self._project) @@ -150,8 +163,10 @@ class NWBuildDocument: yield i, self._doBuild(makeMd, tHandle) self._error = None + self._cache = makeMd + try: - makeMd.saveMarkdown(savePath) + makeMd.saveMarkdown(path) except Exception as exc: self._error = formatException(exc) @@ -194,8 +209,6 @@ class NWBuildDocument: # Get font information if not textFont: textFont = str(CONFIG.textFont) - if not textFont: - textFont = nwConst.SYSTEM_FONT bldFont = QFont(textFont, textSize) fontInfo = QFontInfo(bldFont) diff --git a/novelwriter/tools/manusbuild.py b/novelwriter/tools/manusbuild.py new file mode 100644 index 00000000..4dd8343c --- /dev/null +++ b/novelwriter/tools/manusbuild.py @@ -0,0 +1,42 @@ +""" +novelWriter – GUI Build Manuscript +================================== +GUI classes for the Manuscript Build Tool + +File History: +Created: 2023-05-24 [2.1b1] + +This file is a part of novelWriter +Copyright 2018–2023, 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 . +""" +from __future__ import annotations + +import logging + +from PyQt5.QtWidgets import QDialog, QWidget + +logger = logging.getLogger(__name__) + + +class GuiManuscriptBuild(QDialog): + + def __init__(self, parent: QWidget): + super().__init__(parent=parent) + self._parent = parent + + return + +# END Class GuiManuscriptBuild diff --git a/novelwriter/tools/manuscript.py b/novelwriter/tools/manuscript.py index e638776a..59abadbd 100644 --- a/novelwriter/tools/manuscript.py +++ b/novelwriter/tools/manuscript.py @@ -6,7 +6,6 @@ GUI classes for the Manuscript Build Tool File History: Created: 2023-05-13 [2.1b1] GuiManuscript Created: 2023-05-13 [2.1b1] GuiManuscriptPreview -Created: 2023-05-24 [2.1b1] GuiManuscriptBuild This file is a part of novelWriter Copyright 2018–2023, Veronica Berglyd Olsen @@ -26,10 +25,13 @@ along with this program. If not, see . """ from __future__ import annotations +import json import logging +from time import time from typing import TYPE_CHECKING +from PyQt5.QtGui import QCursor from PyQt5.QtCore import Qt, pyqtSlot from PyQt5.QtWidgets import ( QDialog, QHBoxLayout, QListWidget, QListWidgetItem, QMenu, QProgressBar, @@ -37,6 +39,9 @@ from PyQt5.QtWidgets import ( ) from novelwriter import CONFIG +from novelwriter.error import logException +from novelwriter.core.tohtml import ToHtml +from novelwriter.core.docbuild import NWBuildDocument from novelwriter.core.buildsettings import BuildCollection, BuildSettings from novelwriter.tools.manussettings import GuiBuildSettings @@ -92,7 +97,8 @@ class GuiManuscript(QDialog): self.buildProgress = QProgressBar() self.btnPreview = QPushButton(self.tr("Build Preview")) - self.manPreview = GuiManuscriptPreview(self) + self.btnPreview.clicked.connect(self._generatePreview) + self.manPreview = GuiManuscriptPreview(self.mainGui) self.menuPrint = QMenu(self) self.aPrintSend = self.menuPrint.addAction(self.tr("Print Preview")) @@ -153,6 +159,7 @@ class GuiManuscript(QDialog): self.outerBox.addWidget(self.mainSplit) self.setLayout(self.outerBox) + self.setSizeGripEnabled(True) logger.debug("GuiManuscript initialisation complete") @@ -193,11 +200,9 @@ class GuiManuscript(QDialog): def _editSelectedBuild(self): """Edit the currently selected build settings entry. """ - bItems = self.buildList.selectedItems() - if bItems: - build = self._builds.getBuild(bItems[0].data(Qt.UserRole)) - if isinstance(build, BuildSettings): - self._openSettingsDialog(build) + build = self._getSelectedBuild() + if build is not None: + self._openSettingsDialog(build) return @pyqtSlot(BuildSettings) @@ -209,6 +214,43 @@ class GuiManuscript(QDialog): self._updateBuildItem(build) return + @pyqtSlot() + def _generatePreview(self): + """ + """ + build = self._getSelectedBuild() + if build is None: + return + + docBuild = NWBuildDocument(self.theProject, build) + docBuild.queueAll() + + self.buildProgress.setMaximum(len(docBuild)) + for step, status in docBuild.iterBuildHTML(None): + self.buildProgress.setValue(step + 1) + + buildObj = docBuild.lastBuild + assert isinstance(buildObj, ToHtml) + result = { + "time": int(time()), + "style": buildObj.getStyleSheet(), + "html": buildObj.fullHTML, + } + + logger.debug("Saving build cache") + cache = CONFIG.dataPath("cache") / f"build_{build.buildID}.json" + try: + with open(cache, mode="w+", encoding="utf-8") as outFile: + outFile.write(json.dumps(result, indent=2)) + except Exception: + logger.error("Failed to save build cache") + logException() + return + + self.manPreview.setContent(result) + + return + @pyqtSlot() def _doClose(self): """The close button has been clicked. @@ -221,6 +263,16 @@ class GuiManuscript(QDialog): # Internal Functions ## + def _getSelectedBuild(self) -> BuildSettings | None: + """Get the currently selected build. + """ + bItems = self.buildList.selectedItems() + if bItems: + build = self._builds.getBuild(bItems[0].data(Qt.UserRole)) + if isinstance(build, BuildSettings): + return build + return None + def _saveManuscript(self, outFormat: int): """Save the manuscript file or files. """ @@ -244,8 +296,8 @@ class GuiManuscript(QDialog): winHeight = CONFIG.rpxInt(self.height()) mainSplit = self.mainSplit.sizes() - optsWidth = mainSplit[0] - viewWidth = mainSplit[1] + optsWidth = CONFIG.rpxInt(mainSplit[0]) + viewWidth = CONFIG.rpxInt(mainSplit[1]) pOptions = self.theProject.options pOptions.setValue("GuiManuscript", "winWidth", winWidth) @@ -266,6 +318,7 @@ class GuiManuscript(QDialog): qApp.processEvents() dlgSettings.loadContent() dlgSettings.newSettingsReady.connect(self._processNewSettings) + return def _updateBuildsList(self): @@ -295,7 +348,7 @@ class GuiManuscript(QDialog): class GuiManuscriptPreview(QTextBrowser): - def __init__(self, mainGui): + def __init__(self, mainGui: GuiMain): super().__init__(parent=mainGui) self.mainGui = mainGui @@ -304,15 +357,25 @@ class GuiManuscriptPreview(QTextBrowser): return -# END Class GuiManuscriptPreview + def setContent(self, data: dict): + """ + """ + sPos = self.verticalScrollBar().value() + qApp.setOverrideCursor(QCursor(Qt.WaitCursor)) + html = "".join(data.get("html", [])) + html = html.replace("\t", "!!tab!!") + html = html.replace("", "") + html = html.replace("", "") + self.setHtml(html) + qApp.processEvents() -class GuiManuscriptBuild(QDialog): + while self.find("!!tab!!"): + theCursor = self.textCursor() + theCursor.insertText("\t") - def __init__(self, parent): - super().__init__(parent=parent) - self._manusGui = parent + self.verticalScrollBar().setValue(sPos) return -# END Class GuiManuscriptBuild +# END Class GuiManuscriptPreview diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index 5f2a904e..c638edc9 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -398,7 +398,7 @@ class GuiBuildFilterTab(QWidget): sizes = self.mainSplit.sizes() if len(sizes) < 2: return 0, 0 - return sizes[0], sizes[1] + return CONFIG.rpxInt(sizes[0]), CONFIG.rpxInt(sizes[1]) ## # Slots diff --git a/sample/nwProject.nwx b/sample/nwProject.nwx index afb0157a..6d04fd18 100644 --- a/sample/nwProject.nwx +++ b/sample/nwProject.nwx @@ -1,6 +1,6 @@ - - + + Sample Project Sample Project Jane Smith @@ -65,7 +65,7 @@ Chapter One - + Making a Scene diff --git a/tests/test_core/test_core_docbuild.py b/tests/test_core/test_core_docbuild.py index dde00264..0dabad71 100644 --- a/tests/test_core/test_core_docbuild.py +++ b/tests/test_core/test_core_docbuild.py @@ -80,7 +80,7 @@ def testCoreDocBuild_OpenDocument(monkeypatch, mockGUI, prjLipsum, fncPath, tstP docBuild = NWBuildDocument(project, build) docBuild.queueAll() - assert docBuild.buildLength == 21 + assert len(docBuild) == 21 # Check FODT Build # ================ @@ -139,7 +139,7 @@ def testCoreDocBuild_OpenDocument(monkeypatch, mockGUI, prjLipsum, fncPath, tstP mp.setattr("novelwriter.core.toodt.ToOdt.doConvert", causeException) docBuild.addDocument("0000000000000") - assert docBuild.buildLength == 22 + assert len(docBuild) == 22 count = 0 error = [] @@ -188,7 +188,7 @@ def testCoreDocBuild_HTML(monkeypatch, mockGUI, prjLipsum, fncPath, tstPaths): docBuild = NWBuildDocument(project, build) docBuild.queueAll() - assert docBuild.buildLength == 21 + assert len(docBuild) == 21 # Check HTML5 Build # ================= @@ -239,7 +239,7 @@ def testCoreDocBuild_Markdown(monkeypatch, mockGUI, prjLipsum, fncPath, tstPaths docBuild = NWBuildDocument(project, build) docBuild.queueAll() - assert docBuild.buildLength == 21 + assert len(docBuild) == 21 # Check Standard Markdown Build # ============================= From 8fdec71f4b49adb3f3e57d3ccbb1d9817fcef3f3 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 1 Jun 2023 00:12:27 +0200 Subject: [PATCH 42/74] Improve how manuscript dialog closing is handled --- novelwriter/tools/manuscript.py | 33 ++++++++------- novelwriter/tools/manussettings.py | 64 ++++++++++++++++++------------ 2 files changed, 54 insertions(+), 43 deletions(-) diff --git a/novelwriter/tools/manuscript.py b/novelwriter/tools/manuscript.py index 59abadbd..fe4c6693 100644 --- a/novelwriter/tools/manuscript.py +++ b/novelwriter/tools/manuscript.py @@ -56,7 +56,7 @@ class GuiManuscript(QDialog): def __init__(self, mainGui: GuiMain): super().__init__(parent=mainGui) - logger.debug("Initialising GuiManuscript ...") + logger.debug("Created: GuiManuscript") self.setObjectName("GuiManuscript") self.mainGui = mainGui @@ -161,10 +161,14 @@ class GuiManuscript(QDialog): self.setLayout(self.outerBox) self.setSizeGripEnabled(True) - logger.debug("GuiManuscript initialisation complete") + logger.debug("Ready: GuiManuscript") return + def __del__(self): + logger.debug("Deleted: GuiManuscript") + return + def loadContent(self): """Load dialog content from project data. """ @@ -179,8 +183,13 @@ class GuiManuscript(QDialog): def closeEvent(self, event): """Capture the user closing the window so we can save settings. """ - self._beforeClose() + self._saveSettings() + for obj in self.children(): + # Make sure we don't have any settings windows open + if isinstance(obj, GuiBuildSettings) and obj.isVisible(): + obj.close() event.accept() + self.deleteLater() return ## @@ -216,7 +225,8 @@ class GuiManuscript(QDialog): @pyqtSlot() def _generatePreview(self): - """ + """Run the document builder on the current build settings for + the preview widget. """ build = self._getSelectedBuild() if build is None: @@ -255,7 +265,6 @@ class GuiManuscript(QDialog): def _doClose(self): """The close button has been clicked. """ - self._beforeClose() self.close() return @@ -278,15 +287,6 @@ class GuiManuscript(QDialog): """ return - def _beforeClose(self): - """List of things to do before closing. - """ - self._saveSettings() - for qWidget in qApp.topLevelWidgets(): - if qWidget.objectName() == "GuiBuildSettings": - qWidget.close() - return - def _saveSettings(self): """Save the various user settings. """ @@ -309,16 +309,15 @@ class GuiManuscript(QDialog): return def _openSettingsDialog(self, build: BuildSettings): - """Open a new build settings dialog. + """Open the build settings dialog. """ - dlgSettings = GuiBuildSettings(self.mainGui, build) + dlgSettings = GuiBuildSettings(self, self.mainGui, build) dlgSettings.setModal(False) dlgSettings.show() dlgSettings.raise_() qApp.processEvents() dlgSettings.loadContent() dlgSettings.newSettingsReady.connect(self._processNewSettings) - return def _updateBuildsList(self): diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index c638edc9..c68eae80 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -65,10 +65,10 @@ class GuiBuildSettings(QDialog): newSettingsReady = pyqtSignal(BuildSettings) - def __init__(self, mainGui: GuiMain, build: BuildSettings): - super().__init__(parent=mainGui) + def __init__(self, parent: QWidget, mainGui: GuiMain, build: BuildSettings): + super().__init__(parent=parent) - logger.debug("Initialising GuiBuildSettings ...") + logger.debug("Created: GuiBuildSettings") self.setObjectName("GuiBuildSettings") self.mainGui = mainGui @@ -159,10 +159,13 @@ class GuiBuildSettings(QDialog): # Set Default Tab self.optSideBar.setSelected(self.OPT_FILTERS) - logger.debug("GuiBuildSettings initialisation complete") + logger.debug("Ready: GuiBuildSettings") return + def __del__(self): + logger.debug("Deleted: GuiBuildSettings") + def loadContent(self): """Populate the child widgets. """ @@ -199,21 +202,13 @@ class GuiBuildSettings(QDialog): """Handle button clicks from the dialog button box. """ role = self.dlgButtons.buttonRole(button) - if role in (QDialogButtonBox.ApplyRole, QDialogButtonBox.AcceptRole): - self._build.setName(self.editBuildName.text()) - self.optTabHeadings.saveContent() - self.optTabContent.saveContent() - self.optTabFormat.saveContent() - self.optTabOutput.saveContent() - self.newSettingsReady.emit(self._build) - - self._saveSettings() - if role == QDialogButtonBox.AcceptRole: - self.accept() + if role == QDialogButtonBox.ApplyRole: + self._emitBuildData() + elif role == QDialogButtonBox.AcceptRole: + self._emitBuildData() + self.close() elif role == QDialogButtonBox.RejectRole: - if self._checkOkClose(): - self.reject() - + self.close() return ## @@ -223,25 +218,30 @@ class GuiBuildSettings(QDialog): def closeEvent(self, event: QEvent): """Capture the user closing the window so we can save settings. """ - if self._checkOkClose(): - self._saveSettings() - event.accept() + logger.debug("Closing: GuiBuildSettings") + self._askToSaveBuild() + self._saveSettings() + event.accept() + self.deleteLater() return ## # Internal Functions ## - def _checkOkClose(self) -> bool: + def _askToSaveBuild(self): """Check if there are unsaved changes, and if there are, ask if - it';'s ok to reject them. + it's ok to reject them. """ if self._build.changed: - return self.mainGui.askQuestion( + doSave = self.mainGui.askQuestion( self.tr("Build Settings"), - self.tr("There are unsaved changes. Close anyway?") + self.tr("Do you want to save your changes?") ) - return True + if doSave: + self._emitBuildData() + self._build.resetChangedState() + return def _saveSettings(self): """Save the various user settings. @@ -262,6 +262,18 @@ class GuiBuildSettings(QDialog): return + def _emitBuildData(self): + """Assemble the build data and emit the signal. + """ + self._build.setName(self.editBuildName.text()) + self.optTabHeadings.saveContent() + self.optTabContent.saveContent() + self.optTabFormat.saveContent() + self.optTabOutput.saveContent() + self.newSettingsReady.emit(self._build) + self._build.resetChangedState() + return + # END Class GuiBuildSettings From 3e34721b661d10a263a92b4a36ae5fcbe4cf8c64 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 1 Jun 2023 00:31:42 +0200 Subject: [PATCH 43/74] Make gui class debug messages consistent --- novelwriter/dialogs/about.py | 8 ++++++-- novelwriter/dialogs/docmerge.py | 8 ++++++-- novelwriter/dialogs/docsplit.py | 8 ++++++-- novelwriter/dialogs/preferences.py | 8 ++++++-- novelwriter/dialogs/projdetails.py | 8 ++++++-- novelwriter/dialogs/projload.py | 8 ++++++-- novelwriter/dialogs/projsettings.py | 8 ++++++-- novelwriter/dialogs/updates.py | 8 ++++++-- novelwriter/dialogs/wordlist.py | 8 ++++++-- novelwriter/gui/doceditor.py | 16 ++++++++-------- novelwriter/gui/dochighlight.py | 5 +++-- novelwriter/gui/docviewer.py | 17 +++++++++-------- novelwriter/gui/itemdetails.py | 5 +++-- novelwriter/gui/mainmenu.py | 5 +++-- novelwriter/gui/noveltree.py | 8 ++++---- novelwriter/gui/outline.py | 12 ++++++------ novelwriter/gui/projtree.py | 8 ++++---- novelwriter/gui/sidebar.py | 4 ++-- novelwriter/gui/statusbar.py | 4 ++-- novelwriter/guimain.py | 4 ++-- novelwriter/tools/lipsum.py | 8 ++++++-- novelwriter/tools/manusbuild.py | 10 ++++++++++ novelwriter/tools/manuscript.py | 4 ++-- novelwriter/tools/manussettings.py | 4 ++-- novelwriter/tools/projwizard.py | 8 ++++++-- novelwriter/tools/writingstats.py | 8 ++++++-- 26 files changed, 132 insertions(+), 70 deletions(-) diff --git a/novelwriter/dialogs/about.py b/novelwriter/dialogs/about.py index 607cada2..d2f7d512 100644 --- a/novelwriter/dialogs/about.py +++ b/novelwriter/dialogs/about.py @@ -47,7 +47,7 @@ class GuiAbout(QDialog): def __init__(self, mainGui): super().__init__(parent=mainGui) - logger.debug("Initialising GuiAbout ...") + logger.debug("Create: GuiAbout") self.setObjectName("GuiAbout") self.mainGui = mainGui @@ -110,10 +110,14 @@ class GuiAbout(QDialog): self.outerBox.addWidget(self.buttonBox) self.setLayout(self.outerBox) - logger.debug("GuiAbout initialisation complete") + logger.debug("Ready: GuiAbout") return + def __del__(self): + logger.debug("Delete: GuiAbout") + return + def populateGUI(self): """Populate tabs with text. """ diff --git a/novelwriter/dialogs/docmerge.py b/novelwriter/dialogs/docmerge.py index 32d7770c..ea04d184 100644 --- a/novelwriter/dialogs/docmerge.py +++ b/novelwriter/dialogs/docmerge.py @@ -44,7 +44,7 @@ class GuiDocMerge(QDialog): def __init__(self, mainGui, sHandle, itemList): super().__init__(parent=mainGui) - logger.debug("Initialising GuiDocMerge ...") + logger.debug("Create: GuiDocMerge") self.setObjectName("GuiDocMerge") self.mainGui = mainGui @@ -107,10 +107,14 @@ class GuiDocMerge(QDialog): # Load Content self._loadContent(sHandle, itemList) - logger.debug("GuiDocMerge initialisation complete") + logger.debug("Ready: GuiDocMerge") return + def __del__(self): + logger.debug("Delete: GuiDocMerge") + return + def getData(self): """Return the user's choices. """ diff --git a/novelwriter/dialogs/docsplit.py b/novelwriter/dialogs/docsplit.py index c1f97e2a..d66e0278 100644 --- a/novelwriter/dialogs/docsplit.py +++ b/novelwriter/dialogs/docsplit.py @@ -48,7 +48,7 @@ class GuiDocSplit(QDialog): def __init__(self, mainGui, sHandle): super().__init__(parent=mainGui) - logger.debug("Initialising GuiDocSplit ...") + logger.debug("Create: GuiDocSplit") self.setObjectName("GuiDocSplit") self.mainGui = mainGui @@ -138,10 +138,14 @@ class GuiDocSplit(QDialog): # Load Content self._loadContent(sHandle) - logger.debug("GuiDocSplit initialisation complete") + logger.debug("Ready: GuiDocSplit") return + def __del__(self): + logger.debug("Delete: GuiDocSplit") + return + def getData(self): """Return the user's choices. Also save the users options for the next time the dialog is used. diff --git a/novelwriter/dialogs/preferences.py b/novelwriter/dialogs/preferences.py index 6a685eaf..5d89cadc 100644 --- a/novelwriter/dialogs/preferences.py +++ b/novelwriter/dialogs/preferences.py @@ -46,7 +46,7 @@ class GuiPreferences(NPagedDialog): def __init__(self, mainGui): super().__init__(parent=mainGui) - logger.debug("Initialising GuiPreferences ...") + logger.debug("Create: GuiPreferences") self.setObjectName("GuiPreferences") self.mainGui = mainGui @@ -83,10 +83,14 @@ class GuiPreferences(NPagedDialog): self._needsRestart = False self._refreshTree = False - logger.debug("GuiPreferences initialisation complete") + logger.debug("Ready: GuiPreferences") return + def __del__(self): + logger.debug("Delete: GuiPreferences") + return + ## # Properties ## diff --git a/novelwriter/dialogs/projdetails.py b/novelwriter/dialogs/projdetails.py index 1ae21ab7..d4cff393 100644 --- a/novelwriter/dialogs/projdetails.py +++ b/novelwriter/dialogs/projdetails.py @@ -48,7 +48,7 @@ class GuiProjectDetails(NPagedDialog): def __init__(self, mainGui): super().__init__(parent=mainGui) - logger.debug("Initialising GuiProjectDetails ...") + logger.debug("Create: GuiProjectDetails") self.setObjectName("GuiProjectDetails") self.mainGui = mainGui @@ -78,10 +78,14 @@ class GuiProjectDetails(NPagedDialog): self.buttonBox.rejected.connect(self._doClose) self.addControls(self.buttonBox) - logger.debug("GuiProjectDetails initialisation complete") + logger.debug("Ready: GuiProjectDetails") return + def __del__(self): + logger.debug("Delete: GuiProjectDetails") + return + def updateValues(self): """Set all the values of the pages. """ diff --git a/novelwriter/dialogs/projload.py b/novelwriter/dialogs/projload.py index 2dfd41f6..f00f1c47 100644 --- a/novelwriter/dialogs/projload.py +++ b/novelwriter/dialogs/projload.py @@ -56,7 +56,7 @@ class GuiProjectLoad(QDialog): def __init__(self, mainGui): super().__init__(parent=mainGui) - logger.debug("Initialising GuiProjectLoad ...") + logger.debug("Create: GuiProjectLoad") self.setObjectName("GuiProjectLoad") self.mainGui = mainGui @@ -145,10 +145,14 @@ class GuiProjectLoad(QDialog): keyDelete.setKey(QKeySequence(Qt.Key_Delete)) keyDelete.activated.connect(self._doDeleteRecent) - logger.debug("GuiProjectLoad initialisation complete") + logger.debug("Ready: GuiProjectLoad") return + def __del__(self): + logger.debug("Delete: GuiProjectLoad") + return + ## # Buttons ## diff --git a/novelwriter/dialogs/projsettings.py b/novelwriter/dialogs/projsettings.py index 0bc0229f..f78efd8c 100644 --- a/novelwriter/dialogs/projsettings.py +++ b/novelwriter/dialogs/projsettings.py @@ -52,7 +52,7 @@ class GuiProjectSettings(NPagedDialog): def __init__(self, mainGui, focusTab=TAB_MAIN): super().__init__(parent=mainGui) - logger.debug("Initialising GuiProjectSettings ...") + logger.debug("Create: GuiProjectSettings") self.setObjectName("GuiProjectSettings") self.mainGui = mainGui @@ -93,10 +93,14 @@ class GuiProjectSettings(NPagedDialog): # Focus Tab self._focusTab(focusTab) - logger.debug("GuiProjectSettings initialisation complete") + logger.debug("Ready: GuiProjectSettings") return + def __del__(self): + logger.debug("Delete: GuiProjectSettings") + return + @property def spellChanged(self): return self._spellChanged diff --git a/novelwriter/dialogs/updates.py b/novelwriter/dialogs/updates.py index 8eaa353e..15e40f8e 100644 --- a/novelwriter/dialogs/updates.py +++ b/novelwriter/dialogs/updates.py @@ -47,7 +47,7 @@ class GuiUpdates(QDialog): def __init__(self, mainGui): super().__init__(parent=mainGui) - logger.debug("Initialising GuiUpdates ...") + logger.debug("Create: GuiUpdates") self.setObjectName("GuiUpdates") self.mainGui = mainGui @@ -113,10 +113,14 @@ class GuiUpdates(QDialog): self.setLayout(self.outerBox) - logger.debug("GuiUpdates initialisation complete") + logger.debug("Ready: GuiUpdates") return + def __del__(self): + logger.debug("Delete: GuiUpdates") + return + def checkLatest(self): """Check for latest release. """ diff --git a/novelwriter/dialogs/wordlist.py b/novelwriter/dialogs/wordlist.py index 73886bab..f2e7ddfe 100644 --- a/novelwriter/dialogs/wordlist.py +++ b/novelwriter/dialogs/wordlist.py @@ -46,7 +46,7 @@ class GuiWordList(QDialog): def __init__(self, mainGui): super().__init__(parent=mainGui) - logger.debug("Initialising GuiWordList ...") + logger.debug("Create: GuiWordList") self.setObjectName("GuiWordList") self.mainGui = mainGui @@ -108,10 +108,14 @@ class GuiWordList(QDialog): self._loadWordList() - logger.debug("GuiWordList initialisation complete") + logger.debug("Ready: GuiWordList") return + def __del__(self): + logger.debug("Delete: GuiWordList") + return + ## # Slots ## diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 2425d6be..499ddf2a 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -78,7 +78,7 @@ class GuiDocEditor(QTextEdit): def __init__(self, mainGui): super().__init__(parent=mainGui) - logger.debug("Initialising GuiDocEditor ...") + logger.debug("Create: GuiDocEditor") # Class Variables self.mainGui = mainGui @@ -183,7 +183,7 @@ class GuiDocEditor(QTextEdit): self.updateSyntaxColours() self.initEditor() - logger.debug("GuiDocEditor initialisation complete") + logger.debug("Ready: GuiDocEditor") return @@ -2227,7 +2227,7 @@ class GuiDocEditSearch(QFrame): def __init__(self, docEditor): super().__init__(parent=docEditor) - logger.debug("Initialising GuiDocEditSearch ...") + logger.debug("Create: GuiDocEditSearch") self.docEditor = docEditor self.mainGui = docEditor.mainGui @@ -2371,7 +2371,7 @@ class GuiDocEditSearch(QFrame): self.updateTheme() - logger.debug("GuiDocEditSearch initialisation complete") + logger.debug("Ready: GuiDocEditSearch") return @@ -2642,7 +2642,7 @@ class GuiDocEditHeader(QWidget): def __init__(self, docEditor): super().__init__(parent=docEditor) - logger.debug("Initialising GuiDocEditHeader ...") + logger.debug("Create: GuiDocEditHeader") self.docEditor = docEditor self.mainGui = docEditor.mainGui @@ -2727,7 +2727,7 @@ class GuiDocEditHeader(QWidget): self.updateTheme() - logger.debug("GuiDocEditHeader initialisation complete") + logger.debug("Ready: GuiDocEditHeader") return @@ -2877,7 +2877,7 @@ class GuiDocEditFooter(QWidget): def __init__(self, docEditor): super().__init__(parent=docEditor) - logger.debug("Initialising GuiDocEditFooter ...") + logger.debug("Create: GuiDocEditFooter") self.docEditor = docEditor self.mainGui = docEditor.mainGui @@ -2971,7 +2971,7 @@ class GuiDocEditFooter(QWidget): self.updateLineCount() self.updateCounts() - logger.debug("GuiDocEditFooter initialisation complete") + logger.debug("Ready: GuiDocEditFooter") return diff --git a/novelwriter/gui/dochighlight.py b/novelwriter/gui/dochighlight.py index a4d86c98..0d366b05 100644 --- a/novelwriter/gui/dochighlight.py +++ b/novelwriter/gui/dochighlight.py @@ -49,7 +49,8 @@ class GuiDocHighlighter(QSyntaxHighlighter): def __init__(self, theDoc, mainGui, spEnchant): super().__init__(theDoc) - logger.debug("Initialising GuiDocHighlighter ...") + logger.debug("Create: GuiDocHighlighter") + self.theDoc = theDoc self.spEnchant = spEnchant self.mainGui = mainGui @@ -76,7 +77,7 @@ class GuiDocHighlighter(QSyntaxHighlighter): self.initHighlighter() - logger.debug("GuiDocHighlighter initialisation complete") + logger.debug("Ready: GuiDocHighlighter") return diff --git a/novelwriter/gui/docviewer.py b/novelwriter/gui/docviewer.py index bae5c39a..be25e4b5 100644 --- a/novelwriter/gui/docviewer.py +++ b/novelwriter/gui/docviewer.py @@ -56,7 +56,7 @@ class GuiDocViewer(QTextBrowser): def __init__(self, mainGui): super().__init__(parent=mainGui) - logger.debug("Initialising GuiDocViewer ...") + logger.debug("Create: GuiDocViewer") # Class Variables self.mainGui = mainGui @@ -88,7 +88,7 @@ class GuiDocViewer(QTextBrowser): self.initViewer() - logger.debug("GuiDocViewer initialisation complete") + logger.debug("Ready: GuiDocViewer") return @@ -679,7 +679,7 @@ class GuiDocViewHeader(QWidget): def __init__(self, docViewer): super().__init__(parent=docViewer) - logger.debug("Initialising GuiDocViewHeader ...") + logger.debug("Create: GuiDocViewHeader") self.docViewer = docViewer self.mainGui = docViewer.mainGui @@ -766,7 +766,7 @@ class GuiDocViewHeader(QWidget): # Fix the Colours self.updateTheme() - logger.debug("GuiDocViewHeader initialisation complete") + logger.debug("Ready: GuiDocViewHeader") return @@ -896,7 +896,7 @@ class GuiDocViewFooter(QWidget): def __init__(self, docViewer): super().__init__(parent=docViewer) - logger.debug("Initialising GuiDocViewFooter ...") + logger.debug("Create: GuiDocViewFooter") self.docViewer = docViewer self.mainGui = docViewer.mainGui @@ -1023,7 +1023,7 @@ class GuiDocViewFooter(QWidget): # Fix the Colours self.updateTheme() - logger.debug("GuiDocViewFooter initialisation complete") + logger.debug("Ready: GuiDocViewFooter") return @@ -1139,7 +1139,8 @@ class GuiDocViewDetails(QScrollArea): def __init__(self, mainGui): super().__init__(parent=mainGui) - logger.debug("Initialising GuiDocViewDetails ...") + logger.debug("Create: GuiDocViewDetails") + self.mainGui = mainGui self.theProject = mainGui.theProject self.mainTheme = mainGui.mainTheme @@ -1168,7 +1169,7 @@ class GuiDocViewDetails(QScrollArea): self.setMinimumHeight(CONFIG.pxInt(50)) self.setFrameStyle(QFrame.NoFrame) - logger.debug("GuiDocViewDetails initialisation complete") + logger.debug("Ready: GuiDocViewDetails") return diff --git a/novelwriter/gui/itemdetails.py b/novelwriter/gui/itemdetails.py index e89ed7e2..be0cf144 100644 --- a/novelwriter/gui/itemdetails.py +++ b/novelwriter/gui/itemdetails.py @@ -40,7 +40,8 @@ class GuiItemDetails(QWidget): def __init__(self, mainGui): super().__init__(parent=mainGui) - logger.debug("Initialising GuiItemDetails ...") + logger.debug("Create: GuiItemDetails") + self.mainGui = mainGui self.theProject = mainGui.theProject self.mainTheme = mainGui.mainTheme @@ -182,7 +183,7 @@ class GuiItemDetails(QWidget): self.mainBox.setColumnMinimumWidth(1, flagWidth) self.mainBox.setColumnMinimumWidth(4, countWidth) - logger.debug("GuiItemDetails initialisation complete") + logger.debug("Ready: GuiItemDetails") return diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py index 93dbc184..8870fa37 100644 --- a/novelwriter/gui/mainmenu.py +++ b/novelwriter/gui/mainmenu.py @@ -49,7 +49,8 @@ class GuiMainMenu(QMenuBar): def __init__(self, mainGui): super().__init__(parent=mainGui) - logger.debug("Initialising GuiMainMenu ...") + logger.debug("Create: GuiMainMenu") + self.mainGui = mainGui self.theProject = mainGui.theProject @@ -69,7 +70,7 @@ class GuiMainMenu(QMenuBar): self._docInsert = self.mainGui.docEditor.insertText self._insertKeyWord = self.mainGui.docEditor.insertKeyWord - logger.debug("GuiMainMenu initialisation complete") + logger.debug("Ready: GuiMainMenu") return diff --git a/novelwriter/gui/noveltree.py b/novelwriter/gui/noveltree.py index d8fe310d..aaf9485d 100644 --- a/novelwriter/gui/noveltree.py +++ b/novelwriter/gui/noveltree.py @@ -196,7 +196,7 @@ class GuiNovelToolBar(QWidget): def __init__(self, novelView): super().__init__(parent=novelView) - logger.debug("Initialising GuiNovelToolBar ...") + logger.debug("Create: GuiNovelToolBar") self.novelView = novelView self.mainGui = novelView.mainGui @@ -264,7 +264,7 @@ class GuiNovelToolBar(QWidget): self.updateTheme() - logger.debug("GuiNovelToolBar initialisation complete") + logger.debug("Ready: GuiNovelToolBar") return @@ -395,7 +395,7 @@ class GuiNovelTree(QTreeWidget): def __init__(self, novelView): super().__init__(parent=novelView) - logger.debug("Initialising GuiNovelTree ...") + logger.debug("Create: GuiNovelTree") self.novelView = novelView self.mainGui = novelView.mainGui @@ -460,7 +460,7 @@ class GuiNovelTree(QTreeWidget): self.initSettings() self.updateTheme() - logger.debug("GuiNovelTree initialisation complete") + logger.debug("Ready: GuiNovelTree") return diff --git a/novelwriter/gui/outline.py b/novelwriter/gui/outline.py index fd288a11..7e282ab7 100644 --- a/novelwriter/gui/outline.py +++ b/novelwriter/gui/outline.py @@ -212,7 +212,7 @@ class GuiOutlineToolBar(QToolBar): def __init__(self, theOutline): super().__init__(parent=theOutline) - logger.debug("Initialising GuiOutlineToolBar ...") + logger.debug("Create: GuiOutlineToolBar") self.mainGui = theOutline.mainGui self.theProject = theOutline.mainGui.theProject @@ -260,7 +260,7 @@ class GuiOutlineToolBar(QToolBar): self.updateTheme() - logger.debug("GuiOutlineToolBar initialisation complete") + logger.debug("Ready: GuiOutlineToolBar") return @@ -369,7 +369,7 @@ class GuiOutlineTree(QTreeWidget): def __init__(self, outlineView): super().__init__(parent=outlineView) - logger.debug("Initialising GuiOutlineTree ...") + logger.debug("Create: GuiOutlineTree") self.outlineView = outlineView self.mainGui = outlineView.mainGui @@ -423,7 +423,7 @@ class GuiOutlineTree(QTreeWidget): self.hiddenStateChanged.emit() - logger.debug("GuiOutlineTree initialisation complete") + logger.debug("Ready: GuiOutlineTree") return @@ -796,7 +796,7 @@ class GuiOutlineDetails(QScrollArea): def __init__(self, theOutline): super().__init__(parent=theOutline) - logger.debug("Initialising GuiOutlineDetails ...") + logger.debug("Create: GuiOutlineDetails") self.theOutline = theOutline self.mainGui = theOutline.mainGui @@ -982,7 +982,7 @@ class GuiOutlineDetails(QScrollArea): self.initSettings() - logger.debug("GuiOutlineDetails initialisation complete") + logger.debug("Ready: GuiOutlineDetails") return diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index e5bce3a6..acc75191 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -215,7 +215,7 @@ class GuiProjectToolBar(QWidget): def __init__(self, projView): super().__init__(parent=projView) - logger.debug("Initialising GuiProjectToolBar ...") + logger.debug("Create: GuiProjectToolBar") self.projView = projView self.projTree = projView.projTree @@ -328,7 +328,7 @@ class GuiProjectToolBar(QWidget): self.setLayout(self.outerBox) self.updateTheme() - logger.debug("GuiProjectToolBar initialisation complete") + logger.debug("Ready: GuiProjectToolBar") return @@ -455,7 +455,7 @@ class GuiProjectTree(QTreeWidget): def __init__(self, projView): super().__init__(parent=projView) - logger.debug("Initialising GuiProjectTree ...") + logger.debug("Create: GuiProjectTree") self.projView = projView self.mainGui = projView.mainGui @@ -522,7 +522,7 @@ class GuiProjectTree(QTreeWidget): # Set custom settings self.initSettings() - logger.debug("GuiProjectTree initialisation complete") + logger.debug("Ready: GuiProjectTree") return diff --git a/novelwriter/gui/sidebar.py b/novelwriter/gui/sidebar.py index 12d26d93..66370b36 100644 --- a/novelwriter/gui/sidebar.py +++ b/novelwriter/gui/sidebar.py @@ -43,7 +43,7 @@ class GuiSideBar(QToolBar): def __init__(self, mainGui): super().__init__(parent=mainGui) - logger.debug("Initialising GuiSideBar ...") + logger.debug("Create: GuiSideBar") self.mainGui = mainGui self.mainTheme = mainGui.mainTheme @@ -122,7 +122,7 @@ class GuiSideBar(QToolBar): self.updateTheme() - logger.debug("GuiSideBar initialisation complete") + logger.debug("Ready: GuiSideBar") return diff --git a/novelwriter/gui/statusbar.py b/novelwriter/gui/statusbar.py index 5d90dcd3..10f16c08 100644 --- a/novelwriter/gui/statusbar.py +++ b/novelwriter/gui/statusbar.py @@ -44,7 +44,7 @@ class GuiMainStatus(QStatusBar): def __init__(self, mainGui): super().__init__(parent=mainGui) - logger.debug("Initialising GuiMainStatus ...") + logger.debug("Create: GuiMainStatus") self.mainGui = mainGui self.mainTheme = mainGui.mainTheme @@ -108,7 +108,7 @@ class GuiMainStatus(QStatusBar): # Other Settings self.setSizeGripEnabled(True) - logger.debug("GuiMainStatus initialisation complete") + logger.debug("Ready: GuiMainStatus") self.updateTheme() self.clearStatus() diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 41dd5904..d3c5a5cc 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -78,7 +78,7 @@ class GuiMain(QMainWindow): def __init__(self): super().__init__() - logger.debug("Initialising GUI ...") + logger.debug("Create: GUI") self.setObjectName("GuiMain") self.threadPool = QThreadPool() @@ -302,7 +302,7 @@ class GuiMain(QMainWindow): if CONFIG.isFullScreen: self.toggleFullScreenMode() - logger.debug("GUI initialisation complete") + logger.debug("Ready: GUI") if __hexversion__[-2] == "a" and logger.getEffectiveLevel() > logging.DEBUG: self.makeAlert(self.tr( diff --git a/novelwriter/tools/lipsum.py b/novelwriter/tools/lipsum.py index 3a6f1f42..af152397 100644 --- a/novelwriter/tools/lipsum.py +++ b/novelwriter/tools/lipsum.py @@ -44,7 +44,7 @@ class GuiLipsum(QDialog): def __init__(self, mainGui): super().__init__(parent=mainGui) - logger.debug("Initialising GuiLipsum ...") + logger.debug("Create: GuiLipsum") self.setObjectName("GuiLipsum") self.mainGui = mainGui @@ -107,10 +107,14 @@ class GuiLipsum(QDialog): self.outerBox.setSpacing(CONFIG.pxInt(16)) self.setLayout(self.outerBox) - logger.debug("GuiLipsum initialisation complete") + logger.debug("Ready: GuiLipsum") return + def __del__(self): + logger.debug("Delete: GuiLipsum") + return + ## # Slots ## diff --git a/novelwriter/tools/manusbuild.py b/novelwriter/tools/manusbuild.py index 4dd8343c..dbb07607 100644 --- a/novelwriter/tools/manusbuild.py +++ b/novelwriter/tools/manusbuild.py @@ -35,8 +35,18 @@ class GuiManuscriptBuild(QDialog): def __init__(self, parent: QWidget): super().__init__(parent=parent) + + logger.debug("Create: GuiManuscriptBuild") + self.setObjectName("GuiManuscriptBuild") + self._parent = parent + logger.debug("Ready: GuiManuscriptBuild") + + return + + def __del__(self): + logger.debug("Delete: GuiManuscriptBuild") return # END Class GuiManuscriptBuild diff --git a/novelwriter/tools/manuscript.py b/novelwriter/tools/manuscript.py index fe4c6693..33e1be9f 100644 --- a/novelwriter/tools/manuscript.py +++ b/novelwriter/tools/manuscript.py @@ -56,7 +56,7 @@ class GuiManuscript(QDialog): def __init__(self, mainGui: GuiMain): super().__init__(parent=mainGui) - logger.debug("Created: GuiManuscript") + logger.debug("Create: GuiManuscript") self.setObjectName("GuiManuscript") self.mainGui = mainGui @@ -166,7 +166,7 @@ class GuiManuscript(QDialog): return def __del__(self): - logger.debug("Deleted: GuiManuscript") + logger.debug("Delete: GuiManuscript") return def loadContent(self): diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index c68eae80..c05bda6b 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -68,7 +68,7 @@ class GuiBuildSettings(QDialog): def __init__(self, parent: QWidget, mainGui: GuiMain, build: BuildSettings): super().__init__(parent=parent) - logger.debug("Created: GuiBuildSettings") + logger.debug("Create: GuiBuildSettings") self.setObjectName("GuiBuildSettings") self.mainGui = mainGui @@ -164,7 +164,7 @@ class GuiBuildSettings(QDialog): return def __del__(self): - logger.debug("Deleted: GuiBuildSettings") + logger.debug("Delete: GuiBuildSettings") def loadContent(self): """Populate the child widgets. diff --git a/novelwriter/tools/projwizard.py b/novelwriter/tools/projwizard.py index fdf5ea3a..1fe6cdc2 100644 --- a/novelwriter/tools/projwizard.py +++ b/novelwriter/tools/projwizard.py @@ -50,7 +50,7 @@ class GuiProjectWizard(QWizard): def __init__(self, mainGui): super().__init__(parent=mainGui) - logger.debug("Initialising GuiProjectWizard ...") + logger.debug("Create: GuiProjectWizard") self.setObjectName("GuiProjectWizard") self.mainGui = mainGui @@ -76,10 +76,14 @@ class GuiProjectWizard(QWizard): self.setOption(QWizard.NoBackButtonOnStartPage, True) - logger.debug("GuiProjectWizard initialisation complete") + logger.debug("Ready: GuiProjectWizard") return + def __del__(self): + logger.debug("Delete: GuiProjectWizard") + return + # END Class GuiProjectWizard diff --git a/novelwriter/tools/writingstats.py b/novelwriter/tools/writingstats.py index 8f4ba724..ce7fc184 100644 --- a/novelwriter/tools/writingstats.py +++ b/novelwriter/tools/writingstats.py @@ -60,7 +60,7 @@ class GuiWritingStats(QDialog): def __init__(self, mainGui): super().__init__(parent=mainGui) - logger.debug("Initialising GuiWritingStats ...") + logger.debug("Create: GuiWritingStats") self.setObjectName("GuiWritingStats") self.mainGui = mainGui @@ -286,10 +286,14 @@ class GuiWritingStats(QDialog): self.setLayout(self.outerBox) - logger.debug("GuiWritingStats initialisation complete") + logger.debug("Ready: GuiWritingStats") return + def __del__(self): + logger.debug("Delete: GuiWritingStats") + return + def populateGUI(self): """Populate list box with data from the log file. """ From 8631ea3d5a54df9be0f8be619eb02158239e951b Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 1 Jun 2023 15:39:42 +0200 Subject: [PATCH 44/74] Update test CI jobs --- .github/workflows/test_linux.yml | 6 ++---- .github/workflows/test_mac.yml | 9 +++------ .github/workflows/test_win.yml | 6 ++---- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/.github/workflows/test_linux.yml b/.github/workflows/test_linux.yml index 4a784e63..951215be 100644 --- a/.github/workflows/test_linux.yml +++ b/.github/workflows/test_linux.yml @@ -14,7 +14,7 @@ jobs: testLinux: strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12-dev"] runs-on: ubuntu-latest steps: - name: Python Setup @@ -30,9 +30,7 @@ jobs: uses: actions/checkout@v3 - name: Install Dependencies (pip) run: | - pip install --upgrade pip - pip install -r requirements.txt - pip install -r requirements-dev.txt + pip install -U -r requirements.txt -r requirements-dev.txt - name: Run Build Commands run: python setup.py qtlrelease sample - name: Run Tests diff --git a/.github/workflows/test_mac.yml b/.github/workflows/test_mac.yml index 36394f43..e4ef9cc4 100644 --- a/.github/workflows/test_mac.yml +++ b/.github/workflows/test_mac.yml @@ -19,17 +19,14 @@ jobs: with: python-version: "3.10" architecture: x64 - - name: Install Packages + - name: Install Packages (brew) run: | brew install enchant - name: Checkout Source uses: actions/checkout@v3 - - name: Install Dependencies + - name: Install Dependencies (pip) run: | - pip install --upgrade pip - pip install -r requirements.txt - pip install -r requirements-dev.txt - pip install pyobjc + pip install -U pyobjc -r requirements.txt -r requirements-dev.txt - name: Run Tests run: | export QT_QPA_PLATFORM=offscreen diff --git a/.github/workflows/test_win.yml b/.github/workflows/test_win.yml index cea804e9..f3197a41 100644 --- a/.github/workflows/test_win.yml +++ b/.github/workflows/test_win.yml @@ -21,11 +21,9 @@ jobs: architecture: x64 - name: Checkout Source uses: actions/checkout@v3 - - name: Install Dependencies + - name: Install Dependencies (pip) run: | - pip install --upgrade pip - pip install -r requirements.txt - pip install -r requirements-dev.txt + pip install -U -r requirements.txt -r requirements-dev.txt - name: Run Tests run: | pytest -v --cov=novelwriter --timeout=60 From dbb8f4fa5a895620d0b0be3455e22cd85a56faf9 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 1 Jun 2023 15:41:56 +0200 Subject: [PATCH 45/74] Remove lxml as a test dependency --- requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b47e1e91..af56101a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,2 @@ pyqt5>=5.10 -lxml>=4.2.0 pyenchant>=3.0.0 From 7ff5ffaebda7380261667b421554f564838a2205 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 1 Jun 2023 15:46:39 +0200 Subject: [PATCH 46/74] Remove python 3.12-dev tests again --- .github/workflows/test_linux.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_linux.yml b/.github/workflows/test_linux.yml index 951215be..6a778d2f 100644 --- a/.github/workflows/test_linux.yml +++ b/.github/workflows/test_linux.yml @@ -14,7 +14,7 @@ jobs: testLinux: strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12-dev"] + python-version: ["3.8", "3.9", "3.10", "3.11"] runs-on: ubuntu-latest steps: - name: Python Setup From 3a2733eee5f1e9854a0488b6512aa9ef7a26716e Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 3 Jun 2023 15:46:51 +0200 Subject: [PATCH 47/74] Make new code pass docstring linting --- novelwriter/core/buildsettings.py | 93 +++++++++------------ novelwriter/core/docbuild.py | 38 +++++---- novelwriter/guimain.py | 21 ++++- novelwriter/tools/manusbuild.py | 9 +- novelwriter/tools/manuscript.py | 59 ++++++-------- novelwriter/tools/manussettings.py | 127 ++++++++++++----------------- 6 files changed, 169 insertions(+), 178 deletions(-) diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index cc4b3df1..5a04f00b 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -1,7 +1,6 @@ """ -novelWriter – Build Settings Class -================================== -A class to hold build settings for the build tool +novelWriter – Build Settings +============================ File History: Created: 2023-02-14 [2.1b1] BuildSettings @@ -117,6 +116,7 @@ SETTINGS_LABELS = { class FilterMode(Enum): + """The decision reason for an item in a filtered project.""" UNKNOWN = 0 FILTERED = 1 @@ -128,6 +128,11 @@ class FilterMode(Enum): class BuildSettings: + """Core: Build Settings Class + + This class manages the build settings for a Manuscript build job. + The settings can be packed/unpacked to/from a dictionary for JSON. + """ def __init__(self): self._name = "" @@ -145,14 +150,17 @@ class BuildSettings: @property def name(self) -> str: + """Return the build name.""" return self._name @property def buildID(self) -> str: + """Return the build ID.""" return self._uuid @property def changed(self) -> bool: + """Return the changed status of the build.""" return self._changed ## @@ -161,33 +169,28 @@ class BuildSettings: @staticmethod def getLabel(key: str) -> str: - """Extract the label for a specific item. - """ + """Extract the GUI label for a specific setting.""" return SETTINGS_LABELS.get(key, "ERROR") def getStr(self, key: str) -> str: - """Type safe value access for strings. - """ + """Type safe value access for strings.""" value = self._settings.get(key, SETTINGS_TEMPLATE.get(key, (None, None)[1])) return str(value) def getBool(self, key: str) -> bool: - """Type safe value access for bools. - """ + """Type safe value access for bools.""" value = self._settings.get(key, SETTINGS_TEMPLATE.get(key, (None, None)[1])) return bool(value) def getInt(self, key: str) -> int: - """Type safe value access for integers. - """ + """Type safe value access for integers.""" value = self._settings.get(key, SETTINGS_TEMPLATE.get(key, (None, None)[1])) if isinstance(value, int): return value return 0 def getFloat(self, key: str) -> float: - """Type safe value access for float. - """ + """Type safe value access for float.""" value = self._settings.get(key, SETTINGS_TEMPLATE.get(key, (None, None)[1])) if isinstance(value, float): return value @@ -198,14 +201,12 @@ class BuildSettings: ## def setName(self, name: str): - """Set the build setting display name. - """ + """Set the build setting display name.""" self._name = str(name) return def setBuildID(self, value: str | uuid.UUID): - """Set a UUID build ID. - """ + """Set a UUID build ID.""" value = checkUuid(value, "") if not value: self._uuid = str(uuid.uuid4()) @@ -214,32 +215,28 @@ class BuildSettings: return def setFiltered(self, tHandle: str): - """Set an item as filtered. - """ + """Set an item as filtered.""" self._excluded.discard(tHandle) self._included.discard(tHandle) self._changed = True return def setIncluded(self, tHandle: str): - """Set an item as explicitly included. - """ + """Set an item as explicitly included.""" self._excluded.discard(tHandle) self._included.add(tHandle) self._changed = True return def setExcluded(self, tHandle: str): - """Set an item as explicitly excluded. - """ + """Set an item as explicitly excluded.""" self._excluded.add(tHandle) self._included.discard(tHandle) self._changed = True return def setSkipRoot(self, tHandle: str, state: bool): - """Set a specific root folder as skipped or not. - """ + """Set a specific root folder as skipped or not.""" if state is True: self._skipRoot.discard(tHandle) self._changed = True @@ -249,8 +246,7 @@ class BuildSettings: return def setValue(self, key: str, value: str | int | bool | float) -> bool: - """Set a specific value for a build setting. - """ + """Set a specific value for a build setting.""" if key not in SETTINGS_TEMPLATE: return False definition = SETTINGS_TEMPLATE[key] @@ -267,19 +263,11 @@ class BuildSettings: # Methods ## - def isFiltered(self, tHandle: str) -> bool: - return tHandle not in self._included and tHandle not in self._excluded - - def isIncluded(self, tHandle: str) -> bool: - return tHandle in self._included - - def isExcluded(self, tHandle: str) -> bool: - return tHandle in self._excluded - def isRootAllowed(self, tHandle: str) -> bool: + """Check if a root handle is allowed in the build.""" return tHandle not in self._skipRoot - def buildItemFilter(self, project: NWProject) -> dict: + def buildItemFilter(self, project: NWProject) -> dict[str, tuple[bool, FilterMode]]: """Return a dictionary of item handles with filter decissions applied. """ @@ -325,15 +313,14 @@ class BuildSettings: return result def resetChangedState(self): - """This must be called when the changes to this class has been - safely saved to file or passed on. + """Reset the changed status of the settings object. This must be + called when the changes have been safely saved or passed on. """ self._changed = False return def pack(self) -> dict: - """Pack all content into a JSON compatible dictionary. - """ + """Pack all content into a JSON compatible dictionary.""" logger.debug("Collecting build setting for '%s'", self._name) return { "name": self._name, @@ -347,8 +334,7 @@ class BuildSettings: } def unpack(self, data: dict): - """Unpack a dictionary and populate the class. - """ + """Unpack a dictionary and populate the class.""" settings = data.get("settings", {}) content = data.get("content", {}) included = content.get("included", []) @@ -377,6 +363,12 @@ class BuildSettings: class BuildCollection: + """Core: Build Collection Class + + This object holds all the build setting objects defined by the given + project. The build settings are saved as a single JSON file in the + project folder. + """ def __init__(self, project: NWProject): self._project = project @@ -384,8 +376,7 @@ class BuildCollection: return def getBuild(self, buildID: str) -> BuildSettings | None: - """Get a specific build settings object. - """ + """Get a specific build settings object.""" if buildID not in self._builds: return None build = BuildSettings() @@ -393,8 +384,7 @@ class BuildCollection: return build def setBuild(self, build: BuildSettings) -> bool: - """Set build settings data in the collection. - """ + """Set build settings data in the collection.""" if not isinstance(build, BuildSettings): return False buildID = build.buildID @@ -402,15 +392,13 @@ class BuildCollection: return True def builds(self) -> Iterable[tuple[str, str]]: - """Iterate over all avaiable builds. - """ + """Iterate over all avaiable builds.""" for buildID in self._builds: yield buildID, self._builds[buildID].get("name", "") return def loadCollection(self) -> bool: - """Load build collections file. - """ + """Load build collections file.""" buildsFile = self._project.storage.getMetaFile(nwFiles.BUILDS_FILE) if not isinstance(buildsFile, Path): return False @@ -442,8 +430,7 @@ class BuildCollection: return True def saveCollection(self) -> bool: - """Save build collections file. - """ + """Save build collections file.""" buildsFile = self._project.storage.getMetaFile(nwFiles.BUILDS_FILE) if not isinstance(buildsFile, Path): return False diff --git a/novelwriter/core/docbuild.py b/novelwriter/core/docbuild.py index b946d20f..2270f708 100644 --- a/novelwriter/core/docbuild.py +++ b/novelwriter/core/docbuild.py @@ -1,10 +1,9 @@ """ -novelWriter – Build Document Tool -================================= -A class to build one or more novelWriter files to a single document +novelWriter – Manuscript Document Builder +========================================= File History: -Created: 2022-12-01 [2.1b1] +Created: 2022-12-01 [2.1b1] NWBuildDocument This file is a part of novelWriter Copyright 2018–2023, Veronica Berglyd Olsen @@ -44,6 +43,11 @@ logger = logging.getLogger(__name__) class NWBuildDocument: + """Core: Manuscript Document Build Class + + This is the core tool that assembles a project and outputs a + manuscript, based on a build definition object (BuildSettings). + """ __slots__ = ("_project", "_build", "_queue", "_error", "_cache") @@ -61,10 +65,15 @@ class NWBuildDocument: @property def error(self) -> str | None: + """Return the last error, if any.""" return self._error @property def lastBuild(self) -> Tokenizer | None: + """Return the build object of the last build process, if any. + This is useful for accessing build details and data after the + build job is completed. + """ return self._cache ## @@ -72,6 +81,7 @@ class NWBuildDocument: ## def __len__(self) -> int: + """Return the length of the build queue.""" return len(self._queue) ## @@ -79,17 +89,17 @@ class NWBuildDocument: ## def addDocument(self, tHandle: str): - """Add a document to the build queue manually. - """ + """Add a document to the build queue manually.""" self._queue.append(tHandle) return def queueAll(self): - """Queue all document as defined by the build setup. - """ + """Queue all document as defined by the build settings.""" filtered = self._build.buildItemFilter(self._project) noteTitles = self._build.getBool("text.addNoteHeadings") for item in self._project.tree: + if not item.itemHandle: + continue if filtered.get(item.itemHandle, False): self._queue.append(item.itemHandle) elif item.isRootType() and noteTitles: @@ -97,8 +107,7 @@ class NWBuildDocument: return def iterBuildOpenDocument(self, path: Path, isFlat: bool) -> Iterable[tuple[int, bool]]: - """Build an Open Document file. - """ + """Build an Open Document file.""" makeOdt = ToOdt(self._project, isFlat=isFlat) self._setupBuild(makeOdt) makeOdt.initDocument() @@ -146,8 +155,7 @@ class NWBuildDocument: return def iterBuildMarkdown(self, path: Path, extendedMd: bool) -> Iterable[tuple[int, bool]]: - """Build a Markdown file. - """ + """Build a Markdown file.""" makeMd = ToMarkdown(self._project) self._setupBuild(makeMd) @@ -177,8 +185,7 @@ class NWBuildDocument: ## def _setupBuild(self, bldObj: Tokenizer): - """Configure the build object. - """ + """Configure the build object.""" # Get Settings fmtTitle = self._build.getStr("headings.fmtTitle") fmtChapter = self._build.getStr("headings.fmtChapter") @@ -240,8 +247,7 @@ class NWBuildDocument: return def _doBuild(self, bldObj: Tokenizer, tHandle: str) -> bool: - """Build a single document and add it to the build object. - """ + """Build a single document and add it to the build object.""" self._error = None tItem = self._project.tree[tHandle] if tItem is None: diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index d3c5a5cc..f2922b24 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -1,10 +1,9 @@ """ novelWriter – GUI Main Window ============================= -The main application window File History: -Created: 2018-09-22 [0.0.1] +Created: 2018-09-22 [0.0.1] GuiMain This file is a part of novelWriter Copyright 2018–2023, Veronica Berglyd Olsen @@ -74,6 +73,24 @@ logger = logging.getLogger(__name__) class GuiMain(QMainWindow): + """Main GUI Window + + The Main GUI window class. It is the entry point of the + application, and holds all runtime objects aside from the main + Config instance, which is created before the Main GUI. + + The Main GUI is split up into GUI components, assembled in the init + function. Also, the project instance and theme instance are created + here. These should be passed around to all other objects who need + them and new instances of them should generally not be created. + + * All other GUI classes that depend on any components from the + main GUI should be passed a reference to the instance of this + class. + * All non-GUI classes can be passed a reference to the NWProject + instance if the Main GUI is not needed (which it generally + shouldn't need). + """ def __init__(self): super().__init__() diff --git a/novelwriter/tools/manusbuild.py b/novelwriter/tools/manusbuild.py index dbb07607..199dbf7f 100644 --- a/novelwriter/tools/manusbuild.py +++ b/novelwriter/tools/manusbuild.py @@ -1,10 +1,9 @@ """ novelWriter – GUI Build Manuscript ================================== -GUI classes for the Manuscript Build Tool File History: -Created: 2023-05-24 [2.1b1] +Created: 2023-05-24 [2.1b1] GuiManuscriptBuild This file is a part of novelWriter Copyright 2018–2023, Veronica Berglyd Olsen @@ -32,6 +31,11 @@ logger = logging.getLogger(__name__) class GuiManuscriptBuild(QDialog): + """GUI Tools: Manucript Builder Dialog + + This is the tool for running the build itself. It can be accessed + independently of the Manuscript Build Tool. + """ def __init__(self, parent: QWidget): super().__init__(parent=parent) @@ -46,6 +50,7 @@ class GuiManuscriptBuild(QDialog): return def __del__(self): + """For debug use only.""" logger.debug("Delete: GuiManuscriptBuild") return diff --git a/novelwriter/tools/manuscript.py b/novelwriter/tools/manuscript.py index 33e1be9f..f8ed9d34 100644 --- a/novelwriter/tools/manuscript.py +++ b/novelwriter/tools/manuscript.py @@ -1,11 +1,9 @@ """ -novelWriter – GUI Build Manuscript -================================== -GUI classes for the Manuscript Build Tool +novelWriter – GUI Manuscript Tool +================================= File History: Created: 2023-05-13 [2.1b1] GuiManuscript -Created: 2023-05-13 [2.1b1] GuiManuscriptPreview This file is a part of novelWriter Copyright 2018–2023, Veronica Berglyd Olsen @@ -52,6 +50,12 @@ logger = logging.getLogger(__name__) class GuiManuscript(QDialog): + """GUI Tools: Manuscript Tool + + The dialog displays all the users build definitions, a preview panel + for the manuscript, and can trigger the actual build dialog to build + a document directly to disk. + """ def __init__(self, mainGui: GuiMain): super().__init__(parent=mainGui) @@ -98,7 +102,7 @@ class GuiManuscript(QDialog): self.buildProgress = QProgressBar() self.btnPreview = QPushButton(self.tr("Build Preview")) self.btnPreview.clicked.connect(self._generatePreview) - self.manPreview = GuiManuscriptPreview(self.mainGui) + self.manPreview = _PreviewWidget(self.mainGui) self.menuPrint = QMenu(self) self.aPrintSend = self.menuPrint.addAction(self.tr("Print Preview")) @@ -166,12 +170,12 @@ class GuiManuscript(QDialog): return def __del__(self): + """For debug use only.""" logger.debug("Delete: GuiManuscript") return def loadContent(self): - """Load dialog content from project data. - """ + """Load dialog content from project data.""" self._builds.loadCollection() self._updateBuildsList() return @@ -181,7 +185,9 @@ class GuiManuscript(QDialog): ## def closeEvent(self, event): - """Capture the user closing the window so we can save settings. + """Capture the user closing the window so we can save GUI + settings. We also check that we don't have a build settings + diralog open. """ self._saveSettings() for obj in self.children(): @@ -198,8 +204,7 @@ class GuiManuscript(QDialog): @pyqtSlot() def _createNewBuild(self): - """Open the build settings dialog for a new build. - """ + """Open the build settings dialog for a new build.""" build = BuildSettings() build.setName(self.tr("My Manuscript")) self._openSettingsDialog(build) @@ -207,8 +212,7 @@ class GuiManuscript(QDialog): @pyqtSlot() def _editSelectedBuild(self): - """Edit the currently selected build settings entry. - """ + """Edit the currently selected build settings entry.""" build = self._getSelectedBuild() if build is not None: self._openSettingsDialog(build) @@ -216,8 +220,7 @@ class GuiManuscript(QDialog): @pyqtSlot(BuildSettings) def _processNewSettings(self, build: BuildSettings): - """Process new build settings from the settings dialog. - """ + """Process new build settings from the settings dialog.""" self._builds.setBuild(build) self._builds.saveCollection() self._updateBuildItem(build) @@ -263,8 +266,7 @@ class GuiManuscript(QDialog): @pyqtSlot() def _doClose(self): - """The close button has been clicked. - """ + """Forward the close button to the default close method.""" self.close() return @@ -273,8 +275,7 @@ class GuiManuscript(QDialog): ## def _getSelectedBuild(self) -> BuildSettings | None: - """Get the currently selected build. - """ + """Get the currently selected build.""" bItems = self.buildList.selectedItems() if bItems: build = self._builds.getBuild(bItems[0].data(Qt.UserRole)) @@ -283,13 +284,11 @@ class GuiManuscript(QDialog): return None def _saveManuscript(self, outFormat: int): - """Save the manuscript file or files. - """ + """Save the manuscript file or files.""" return def _saveSettings(self): - """Save the various user settings. - """ + """Save the user GUI settings.""" logger.debug("Saving GuiManuscript settings") winWidth = CONFIG.rpxInt(self.width()) @@ -309,8 +308,7 @@ class GuiManuscript(QDialog): return def _openSettingsDialog(self, build: BuildSettings): - """Open the build settings dialog. - """ + """Open the build settings dialog.""" dlgSettings = GuiBuildSettings(self, self.mainGui, build) dlgSettings.setModal(False) dlgSettings.show() @@ -321,8 +319,7 @@ class GuiManuscript(QDialog): return def _updateBuildsList(self): - """Update the list of available builds. - """ + """Update the list of available builds.""" self.buildList.clear() for key, name in self._builds.builds(): bItem = QListWidgetItem() @@ -333,8 +330,7 @@ class GuiManuscript(QDialog): return def _updateBuildItem(self, build: BuildSettings): - """Update the entry of a specific build item. - """ + """Update the entry of a specific build item.""" bItem = self._buildMap.get(build.buildID, None) if isinstance(bItem, QListWidgetItem): bItem.setText(build.name) @@ -345,7 +341,7 @@ class GuiManuscript(QDialog): # END Class GuiManuscript -class GuiManuscriptPreview(QTextBrowser): +class _PreviewWidget(QTextBrowser): def __init__(self, mainGui: GuiMain): super().__init__(parent=mainGui) @@ -357,8 +353,7 @@ class GuiManuscriptPreview(QTextBrowser): return def setContent(self, data: dict): - """ - """ + """Set the content of the preview widget.""" sPos = self.verticalScrollBar().value() qApp.setOverrideCursor(QCursor(Qt.WaitCursor)) @@ -377,4 +372,4 @@ class GuiManuscriptPreview(QTextBrowser): return -# END Class GuiManuscriptPreview +# END Class _PreviewWidget diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index c05bda6b..f97df0c5 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -1,10 +1,9 @@ """ novelWriter – GUI Build Settings ================================ -GUI classes for editing Manuscript Build Settings File History: -Created: 2023-02-13 [2.1b1] +Created: 2023-02-13 [2.1b1] GuiBuildSettings This file is a part of novelWriter Copyright 2018–2023, Veronica Berglyd Olsen @@ -34,8 +33,8 @@ from PyQt5.QtGui import ( from PyQt5.QtCore import QEvent, QSize, Qt, pyqtSignal, pyqtSlot from PyQt5.QtWidgets import ( QAbstractButton, QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, - QDoubleSpinBox, QFontDialog, QFrame, QGridLayout, QHBoxLayout, QHeaderView, QLabel, - QLineEdit, QMenu, QPlainTextEdit, QPushButton, QSpinBox, QSplitter, + QDoubleSpinBox, QFontDialog, QFrame, QGridLayout, QHBoxLayout, QHeaderView, + QLabel, QLineEdit, QMenu, QPlainTextEdit, QPushButton, QSpinBox, QSplitter, QStackedWidget, QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget ) @@ -56,6 +55,11 @@ logger = logging.getLogger(__name__) class GuiBuildSettings(QDialog): + """GUI Tools: Manuscript Build Settings Dialog + + The main tool for configuring manuscript builds. It's a GUI tool for + editing JSON build definitions, wrapped as a BuildSettings object. + """ OPT_FILTERS = 1 OPT_HEADINGS = 2 @@ -112,11 +116,11 @@ class GuiBuildSettings(QDialog): # ============ # Create Tabs - self.optTabSelect = GuiBuildFilterTab(self, self._build) - self.optTabHeadings = GuiBuildHeadingsTab(self, self._build) - self.optTabFormat = GuiBuildFormatTab(self, self._build) - self.optTabContent = GuiBuildContentTab(self, self._build) - self.optTabOutput = GuiBuildOutputTab(self, self._build) + self.optTabSelect = _FilterTab(self, self._build) + self.optTabHeadings = _HeadingsTab(self, self._build) + self.optTabFormat = _FormatTab(self, self._build) + self.optTabContent = _ContentTab(self, self._build) + self.optTabOutput = _OutputTab(self, self._build) # Add Tabs self.toolStack = QStackedWidget(self) @@ -164,11 +168,11 @@ class GuiBuildSettings(QDialog): return def __del__(self): + """For debug use only.""" logger.debug("Delete: GuiBuildSettings") def loadContent(self): - """Populate the child widgets. - """ + """Populate the child widgets.""" self.editBuildName.setText(self._build.name) self.optTabSelect.loadContent() self.optTabHeadings.loadContent() @@ -183,8 +187,7 @@ class GuiBuildSettings(QDialog): @pyqtSlot(int) def _stackPageSelected(self, pageId: int): - """Process a user request to switch page. - """ + """Process a user request to switch page.""" if pageId == self.OPT_FILTERS: self.toolStack.setCurrentWidget(self.optTabSelect) elif pageId == self.OPT_HEADINGS: @@ -199,8 +202,7 @@ class GuiBuildSettings(QDialog): @pyqtSlot("QAbstractButton*") def _dialogButtonClicked(self, button: QAbstractButton): - """Handle button clicks from the dialog button box. - """ + """Handle button clicks from the dialog button box.""" role = self.dlgButtons.buttonRole(button) if role == QDialogButtonBox.ApplyRole: self._emitBuildData() @@ -216,7 +218,8 @@ class GuiBuildSettings(QDialog): ## def closeEvent(self, event: QEvent): - """Capture the user closing the window so we can save settings. + """Capture the user closing the window so we can save + settings. """ logger.debug("Closing: GuiBuildSettings") self._askToSaveBuild() @@ -244,8 +247,7 @@ class GuiBuildSettings(QDialog): return def _saveSettings(self): - """Save the various user settings. - """ + """Save the various user settings.""" logger.debug("Saving GuiBuildSettings settings") winWidth = CONFIG.rpxInt(self.width()) @@ -263,8 +265,7 @@ class GuiBuildSettings(QDialog): return def _emitBuildData(self): - """Assemble the build data and emit the signal. - """ + """Assemble the build data and emit the signal.""" self._build.setName(self.editBuildName.text()) self.optTabHeadings.saveContent() self.optTabContent.saveContent() @@ -277,7 +278,7 @@ class GuiBuildSettings(QDialog): # END Class GuiBuildSettings -class GuiBuildFilterTab(QWidget): +class _FilterTab(QWidget): C_DATA = 0 C_NAME = 0 @@ -398,15 +399,13 @@ class GuiBuildFilterTab(QWidget): return def loadContent(self): - """Populate the widgets. - """ + """Populate the widgets.""" self._populateTree() self._populateFilters() return def mainSplitSizes(self) -> tuple[int, int]: - """Extract the sizes of the main splitter. - """ + """Extract the sizes of the main splitter.""" sizes = self.mainSplit.sizes() if len(sizes) < 2: return 0, 0 @@ -418,8 +417,7 @@ class GuiBuildFilterTab(QWidget): @pyqtSlot(str, bool) def _applyFilterSwitch(self, key: str, state: bool): - """A filter switch has been toggled, so update the settings. - """ + """Apply filter switch and update the settings.""" if key.startswith("doc:"): self._build.setValue(key[4:], state) self._setTreeItemMode() @@ -433,8 +431,7 @@ class GuiBuildFilterTab(QWidget): ## def _populateTree(self): - """Build the tree of project items. - """ + """Build the tree of project items.""" logger.debug("Building project tree") self._treeMap = {} self.optTree.clear() @@ -490,8 +487,7 @@ class GuiBuildFilterTab(QWidget): return def _populateFilters(self): - """Populate the filter options switches. - """ + """Populate the filter options switches.""" self.filterOpt.clear() self.filterOpt.addLabel(self._build.getLabel("filter")) self.filterOpt.addItem( @@ -530,8 +526,7 @@ class GuiBuildFilterTab(QWidget): return def _setSelectedMode(self, mode: int): - """Set the mode for the selected items. - """ + """Set the mode for the selected items.""" for item in self.optTree.selectedItems(): if not isinstance(item, QTreeWidgetItem): continue @@ -551,8 +546,7 @@ class GuiBuildFilterTab(QWidget): return def _setTreeItemMode(self): - """Update the filtered mode icon on all items. - """ + """Update the filtered mode icon on all items.""" filtered = self._build.buildItemFilter(self.theProject) for tHandle, item in self._treeMap.items(): allow, mode = filtered.get(tHandle, (False, FilterMode.UNKNOWN)) @@ -566,10 +560,10 @@ class GuiBuildFilterTab(QWidget): item.setIcon(self.C_STATUS, self._statusFlags[self.F_NONE][1]) return -# END Class GuiBuildFilterTab +# END Class _FilterTab -class GuiBuildHeadingsTab(QWidget): +class _HeadingsTab(QWidget): EDIT_TITLE = 1 EDIT_CHAPTER = 2 @@ -707,7 +701,7 @@ class GuiBuildHeadingsTab(QWidget): self.editTextBox.setFixedHeight(5*iPx) self.editTextBox.setEnabled(False) - self.formSyntax = GuiHeadingSyntax(self.editTextBox.document(), self.mainTheme) + self.formSyntax = _HeadingSyntaxHighlighter(self.editTextBox.document(), self.mainTheme) self.menuInsert = QMenu() self.aInsTitle = self.menuInsert.addAction(self.tr("Title")) @@ -756,8 +750,7 @@ class GuiBuildHeadingsTab(QWidget): return def loadContent(self): - """Populate the widgets. - """ + """Populate the widgets.""" self.fmtTitle.setText(self._build.getStr("headings.fmtTitle")) self.fmtChapter.setText(self._build.getStr("headings.fmtChapter")) self.fmtUnnumbered.setText(self._build.getStr("headings.fmtUnnumbered")) @@ -768,8 +761,7 @@ class GuiBuildHeadingsTab(QWidget): return def saveContent(self): - """Save choices back into build object. - """ + """Save choices back into build object.""" self._build.setValue("headings.hideScene", self.swtScene.isChecked()) self._build.setValue("headings.hideSection", self.swtSection.isChecked()) return @@ -779,8 +771,7 @@ class GuiBuildHeadingsTab(QWidget): ## def _insertIntoForm(self, text: str): - """Insert formatting text from the dropdown menu. - """ + """Insert formatting text from the dropdown menu.""" if self._editing > 0: cursor = self.editTextBox.textCursor() cursor.insertText(text) @@ -788,8 +779,7 @@ class GuiBuildHeadingsTab(QWidget): return def _editHeading(self, heading: int): - """Populate the form with a specific heading format. - """ + """Populate the form with a specific heading format.""" self._editing = heading self.editTextBox.setEnabled(True) if heading == self.EDIT_TITLE: @@ -823,8 +813,7 @@ class GuiBuildHeadingsTab(QWidget): ## def _saveFormat(self): - """Save the format from the edit text box. - """ + """Save the format from the edit text box.""" heading = self._editing text = self.editTextBox.toPlainText().replace("\n", "//") if heading == self.EDIT_TITLE: @@ -850,10 +839,10 @@ class GuiBuildHeadingsTab(QWidget): return -# END Class GuiBuildHeadingsTab +# END Class _HeadingsTab -class GuiHeadingSyntax(QSyntaxHighlighter): +class _HeadingSyntaxHighlighter(QSyntaxHighlighter): def __init__(self, document: QTextDocument, mainTheme: GuiTheme): super().__init__(document) @@ -864,8 +853,7 @@ class GuiHeadingSyntax(QSyntaxHighlighter): return def highlightBlock(self, text: str): - """Add syntax highlighting to the text block. - """ + """Add syntax highlighting to the text block.""" for heading in nwHeadFmt.ALL: pos = text.find(heading) if pos >= 0: @@ -877,10 +865,10 @@ class GuiHeadingSyntax(QSyntaxHighlighter): self.setFormat(pos + ddots, 1, self._fmtSymbol) return -# END Class GuiHeadingSyntax +# END Class _HeadingSyntaxHighlighter -class GuiBuildContentTab(QWidget): +class _ContentTab(QWidget): def __init__(self, buildMain: GuiBuildSettings, build: BuildSettings): super().__init__(parent=buildMain) @@ -932,8 +920,7 @@ class GuiBuildContentTab(QWidget): return def loadContent(self): - """Populate the widgets. - """ + """Populate the widgets.""" self.incSynopsis.setChecked(self._build.getBool("text.includeSynopsis")) self.incComments.setChecked(self._build.getBool("text.includeComments")) self.incKeywords.setChecked(self._build.getBool("text.includeKeywords")) @@ -942,8 +929,7 @@ class GuiBuildContentTab(QWidget): return def saveContent(self): - """Save choices back into build object. - """ + """Save choices back into build object.""" self._build.setValue("text.includeSynopsis", self.incSynopsis.isChecked()) self._build.setValue("text.includeComments", self.incComments.isChecked()) self._build.setValue("text.includeKeywords", self.incKeywords.isChecked()) @@ -951,10 +937,10 @@ class GuiBuildContentTab(QWidget): self._build.setValue("text.addNoteHeadings", self.addNoteHead.isChecked()) return -# END Class GuiBuildContentTab +# END Class _ContentTab -class GuiBuildFormatTab(QWidget): +class _FormatTab(QWidget): def __init__(self, buildMain: GuiBuildSettings, build: BuildSettings): super().__init__(parent=buildMain) @@ -1035,8 +1021,7 @@ class GuiBuildFormatTab(QWidget): return def loadContent(self): - """Populate the widgets. - """ + """Populate the widgets.""" langIdx = self.buildLang.findData(self._build.getStr("format.buildLang")) if langIdx != -1: self.buildLang.setCurrentIndex(langIdx) @@ -1056,8 +1041,7 @@ class GuiBuildFormatTab(QWidget): return def saveContent(self): - """Save choices back into build object. - """ + """Save choices back into build object.""" self._build.setValue("format.buildLang", str(self.buildLang.currentData())) self._build.setValue("format.textFont", self.textFont.text()) self._build.setValue("format.textSize", self.textSize.value()) @@ -1073,8 +1057,7 @@ class GuiBuildFormatTab(QWidget): @pyqtSlot() def _selectFont(self): - """Open the QFontDialog and set a font for the font style. - """ + """Open the QFontDialog and set a font for the font style.""" currFont = QFont() currFont.setFamily(self.textFont.text()) currFont.setPointSize(self.textSize.value()) @@ -1084,10 +1067,10 @@ class GuiBuildFormatTab(QWidget): self.textSize.setValue(theFont.pointSize()) return -# END Class GuiBuildFormatTab +# END Class _FormatTab -class GuiBuildOutputTab(QWidget): +class _OutputTab(QWidget): def __init__(self, buildMain: GuiBuildSettings, build: BuildSettings): super().__init__(parent=buildMain) @@ -1133,17 +1116,15 @@ class GuiBuildOutputTab(QWidget): return def loadContent(self): - """Populate the widgets. - """ + """Populate the widgets.""" self.odtAddColours.setChecked(self._build.getBool("odt.addColours")) self.htmlAddStyles.setChecked(self._build.getBool("html.addStyles")) return def saveContent(self): - """Save choices back into build object. - """ + """Save choices back into build object.""" self._build.setValue("odt.addColours", self.odtAddColours.isChecked()) self._build.setValue("html.addStyles", self.htmlAddStyles.isChecked()) return -# END Class GuiBuildOutputTab +# END Class _OutputTab From 95145785546d80d61c6f96d08ebe9cd373d68f5b Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 3 Jun 2023 16:51:45 +0200 Subject: [PATCH 48/74] Add the build preview functionality from the old build tool --- novelwriter/common.py | 27 ++--- novelwriter/tools/manuscript.py | 187 ++++++++++++++++++++++++++++++-- 2 files changed, 189 insertions(+), 25 deletions(-) diff --git a/novelwriter/common.py b/novelwriter/common.py index 5813ec1e..3ccdd751 100644 --- a/novelwriter/common.py +++ b/novelwriter/common.py @@ -22,7 +22,6 @@ 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 . """ - from __future__ import annotations import json @@ -31,6 +30,7 @@ import hashlib import logging import xml.etree.ElementTree as ET +from typing import Any from pathlib import Path from datetime import datetime from configparser import ConfigParser @@ -49,9 +49,8 @@ logger = logging.getLogger(__name__) # Checker Functions # =============================================================================================== # -def checkStringNone(value, default): - """Check if a variable is a string or a None. - """ +def checkStringNone(value: Any, default: str | None) -> str | None: + """Check if a variable is a string or a None.""" if value is None or value == "None": return None if isinstance(value, str): @@ -59,35 +58,31 @@ def checkStringNone(value, default): return default -def checkString(value, default): - """Check if a variable is a string. - """ +def checkString(value: Any, default: str) -> str: + """Check if a variable is a string.""" if isinstance(value, str): return str(value) return default -def checkInt(value, default): - """Check if a variable is an integer. - """ +def checkInt(value: Any, default: int) -> int: + """Check if a variable is an integer.""" try: return int(value) except Exception: return default -def checkFloat(value, default): - """Check if a variable is a float. - """ +def checkFloat(value: Any, default: float) -> float: + """Check if a variable is a float.""" try: return float(value) except Exception: return default -def checkBool(value, default): - """Check if a variable is a boolean. - """ +def checkBool(value: Any, default: bool) -> bool: + """Check if a variable is a boolean.""" if isinstance(value, bool): return value elif isinstance(value, str): diff --git a/novelwriter/tools/manuscript.py b/novelwriter/tools/manuscript.py index f8ed9d34..2260650a 100644 --- a/novelwriter/tools/manuscript.py +++ b/novelwriter/tools/manuscript.py @@ -28,16 +28,19 @@ import logging from time import time from typing import TYPE_CHECKING +from datetime import datetime -from PyQt5.QtGui import QCursor -from PyQt5.QtCore import Qt, pyqtSlot +from PyQt5.QtGui import QColor, QCursor, QFont, QPalette, QResizeEvent +from PyQt5.QtCore import QTimer, Qt, pyqtSlot from PyQt5.QtWidgets import ( - QDialog, QHBoxLayout, QListWidget, QListWidgetItem, QMenu, QProgressBar, - QPushButton, QSplitter, QTextBrowser, QVBoxLayout, QWidget, qApp + QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QMenu, + QProgressBar, QPushButton, QSplitter, QTextBrowser, QVBoxLayout, QWidget, + qApp ) from novelwriter import CONFIG from novelwriter.error import logException +from novelwriter.common import checkInt, fuzzyTime from novelwriter.core.tohtml import ToHtml from novelwriter.core.docbuild import NWBuildDocument from novelwriter.core.buildsettings import BuildCollection, BuildSettings @@ -178,6 +181,21 @@ class GuiManuscript(QDialog): """Load dialog content from project data.""" self._builds.loadCollection() self._updateBuildsList() + + logger.debug("Loading build cache") + cache = CONFIG.dataPath("cache") / f"build_{self.theProject.data.uuid}.json" + if cache.is_file(): + try: + with open(cache, mode="r", encoding="utf-8") as fObj: + data = json.load(fObj) + build = self._builds.getBuild(data.get("uuid", "")) + if isinstance(build, BuildSettings): + self._updatePreview(data, build) + except Exception: + logger.error("Failed to save build cache") + logException() + return + return ## @@ -245,13 +263,16 @@ class GuiManuscript(QDialog): buildObj = docBuild.lastBuild assert isinstance(buildObj, ToHtml) result = { + "uuid": build.buildID, "time": int(time()), - "style": buildObj.getStyleSheet(), + "styles": buildObj.getStyleSheet(), "html": buildObj.fullHTML, } + self._updatePreview(result, build) + logger.debug("Saving build cache") - cache = CONFIG.dataPath("cache") / f"build_{build.buildID}.json" + cache = CONFIG.dataPath("cache") / f"build_{self.theProject.data.uuid}.json" try: with open(cache, mode="w+", encoding="utf-8") as outFile: outFile.write(json.dumps(result, indent=2)) @@ -260,8 +281,6 @@ class GuiManuscript(QDialog): logException() return - self.manPreview.setContent(result) - return @pyqtSlot() @@ -274,6 +293,19 @@ class GuiManuscript(QDialog): # Internal Functions ## + def _updatePreview(self, data: dict, build: BuildSettings): + """Update the preview widget and set relevant values.""" + self.manPreview.setContent(data) + self.manPreview.setBuildName(build.name) + self.manPreview.setTextFont( + build.getStr("format.textFont"), + build.getInt("format.textSize") + ) + self.manPreview.setJustify( + build.getBool("format.justifyText") + ) + return + def _getSelectedBuild(self) -> BuildSettings | None: """Get the currently selected build.""" bItems = self.buildList.selectedItems() @@ -350,26 +382,163 @@ class _PreviewWidget(QTextBrowser): self.mainTheme = mainGui.mainTheme self.theProject = mainGui.theProject + self._docTime = 0 + self._buildName = "" + + # Document Setup + dPalette = self.palette() + dPalette.setColor(QPalette.Base, QColor(255, 255, 255)) + dPalette.setColor(QPalette.Text, QColor(0, 0, 0)) + self.setPalette(dPalette) + + self.setMinimumWidth(40*self.mainGui.mainTheme.textNWidth) + self.setTextFont(CONFIG.textFont, CONFIG.textSize) + self.setTabStopDistance(CONFIG.getTabWidth()) + self.setOpenExternalLinks(False) + + self.document().setDocumentMargin(CONFIG.getTextMargin()) + self.setPlaceholderText(self.tr( + "Press the \"Build Preview\" button to generate ..." + )) + + # Document Age + aPalette = self.palette() + aPalette.setColor(QPalette.Background, aPalette.toolTipBase().color()) + aPalette.setColor(QPalette.Foreground, aPalette.toolTipText().color()) + + aFont = self.font() + aFont.setPointSizeF(0.9*self.mainTheme.fontPointSize) + + self.ageLabel = QLabel("", self) + self.ageLabel.setIndent(0) + self.ageLabel.setFont(aFont) + self.ageLabel.setPalette(aPalette) + self.ageLabel.setAutoFillBackground(True) + self.ageLabel.setAlignment(Qt.AlignCenter) + self.ageLabel.setFixedHeight(int(2.1*self.mainTheme.fontPixelSize)) + + self._updateDocMargins() + self._updateBuildAge() + + # Age Timer + self.ageTimer = QTimer() + self.ageTimer.setInterval(10) + self.ageTimer.timeout.connect(self._updateBuildAge) + self.ageTimer.start() + return + ## + # Setters + ## + + def setBuildName(self, name: str): + """Set the build name for the document label.""" + self._buildName = name + self._updateBuildAge() + return + + def setJustify(self, state: bool): + """Enable/disable the justify text option.""" + options = self.document().defaultTextOption() + if state: + options.setAlignment(Qt.AlignJustify) + else: + options.setAlignment(Qt.AlignAbsolute) + self.document().setDefaultTextOption(options) + return + + def setTextFont(self, family: str, size: int): + """Set the text font properties.""" + font = QFont() + font.setFamily(family) + font.setPointSize(size) + self.setFont(font) + return + + ## + # Methods + ## + def setContent(self, data: dict): """Set the content of the preview widget.""" sPos = self.verticalScrollBar().value() qApp.setOverrideCursor(QCursor(Qt.WaitCursor)) + styles = "\n".join(data.get("styles", [ + "h1, h2 {color: rgb(66, 113, 174);}", + "h3, h4 {color: rgb(50, 50, 50);}", + "a {color: rgb(66, 113, 174);}", + ".tags {color: rgb(245, 135, 31); font-weight: bold;}", + ])) + self.document().setDefaultStyleSheet(styles) + html = "".join(data.get("html", [])) html = html.replace("\t", "!!tab!!") html = html.replace("", "") html = html.replace("", "") self.setHtml(html) qApp.processEvents() - while self.find("!!tab!!"): theCursor = self.textCursor() theCursor.insertText("\t") self.verticalScrollBar().setValue(sPos) + self._docTime = checkInt(data.get("time"), 0) + self._updateBuildAge() + + # Since we change the content while it may still be rendering, we mark + # the document dirty again to make sure it's re-rendered properly. + self.document().markContentsDirty(0, self.document().characterCount()) + qApp.restoreOverrideCursor() return + ## + # Events + ## + + def resizeEvent(self, event: QResizeEvent): + """Capture resize and update the document margins.""" + super().resizeEvent(event) + self._updateDocMargins() + return + + ## + # Private Slots + ## + + @pyqtSlot() + def _updateBuildAge(self): + """Update the build time and the fuzzy age.""" + if self._docTime > 0: + strBuildTime = "%s (%s)" % ( + datetime.fromtimestamp(self._docTime).strftime("%x %X"), + fuzzyTime(time() - self._docTime) + ) + else: + strBuildTime = self.tr("Unknown") + text = "{0} {1}".format(self.tr("Built"), strBuildTime) + if self._buildName: + text = "{0}
{1}".format(self._buildName, text) + self.ageLabel.setText(text) + return + + ## + # Internal Functions + ## + + def _updateDocMargins(self): + """Automatically adjust the header to fill the top of the + document within the viewport. + """ + vBar = self.verticalScrollBar() + sW = vBar.width() if vBar.isVisible() else 0 + tB = self.frameWidth() + tW = self.width() - 2*tB - sW + tH = self.ageLabel.height() + self.ageLabel.setGeometry(tB, tB, tW, tH) + self.setViewportMargins(0, tH, 0, 0) + return + # END Class _PreviewWidget From 4c439910dcd928e677e10ed6ec3b13f9095041d9 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 3 Jun 2023 17:03:30 +0200 Subject: [PATCH 49/74] Remove the old build tool --- novelwriter/core/options.py | 7 - novelwriter/gui/mainmenu.py | 5 - novelwriter/gui/sidebar.py | 4 +- novelwriter/guimain.py | 21 - novelwriter/tools/build.py | 1425 -------------------------- tests/test_core/test_core_options.py | 84 +- tests/test_gui/test_gui_guimain.py | 2 +- tests/test_tools/test_tools_build.py | 262 ----- 8 files changed, 42 insertions(+), 1768 deletions(-) delete mode 100644 novelwriter/tools/build.py delete mode 100644 tests/test_tools/test_tools_build.py diff --git a/novelwriter/core/options.py b/novelwriter/core/options.py index e0ce2a2c..bad425b1 100644 --- a/novelwriter/core/options.py +++ b/novelwriter/core/options.py @@ -43,13 +43,6 @@ VALID_MAP = { "hideZeros", "hideNegative", "groupByDay", "showIdleTime", "histMax", }, "GuiDocSplit": {"spLevel", "intoFolder", "docHierarchy"}, - "GuiBuildNovel": { - "winWidth", "winHeight", "boxWidth", "docWidth", "hideScene", - "hideSection", "addNovel", "addNotes", "ignoreFlag", "justifyText", - "excludeBody", "textFont", "textSize", "lineHeight", "noStyling", - "incSynopsis", "incComments", "incKeywords", "incBodyText", - "replaceTabs", "replaceUCode", "rootFilter", - }, "GuiOutline": {"headerOrder", "columnWidth", "columnHidden"}, "GuiProjectSettings": { "winWidth", "winHeight", "replaceColW", "statusColW", "importColW", diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py index 8870fa37..80b1f8ae 100644 --- a/novelwriter/gui/mainmenu.py +++ b/novelwriter/gui/mainmenu.py @@ -829,11 +829,6 @@ class GuiMainMenu(QMenuBar): self.aBackupProject.triggered.connect(lambda: self.theProject.backupProject(True)) self.toolsMenu.addAction(self.aBackupProject) - # Tools > Build Novel Project - self.aBuildProject = QAction(self.tr("Build Novel Project"), self) - self.aBuildProject.triggered.connect(self.mainGui.showBuildProjectDialog) - self.toolsMenu.addAction(self.aBuildProject) - # Tools > Build Manuscript self.aBuildManuscript = QAction(self.tr("Build Manuscript"), self) self.aBuildManuscript.setShortcut("F5") diff --git a/novelwriter/gui/sidebar.py b/novelwriter/gui/sidebar.py index 66370b36..637e11a5 100644 --- a/novelwriter/gui/sidebar.py +++ b/novelwriter/gui/sidebar.py @@ -82,8 +82,8 @@ class GuiSideBar(QToolBar): self.aBuild = QAction(self.tr("Build"), self) self.aBuild.setFont(lblFont) - self.aBuild.setToolTip(self.tr("Build Novel Project")) - self.aBuild.triggered.connect(lambda: self.mainGui.showBuildProjectDialog()) + self.aBuild.setToolTip(self.tr("Build Manuscript")) + self.aBuild.triggered.connect(lambda: self.mainGui.showBuildManuscriptDialog()) self.aDetails = QAction(self.tr("Details"), self) self.aDetails.setFont(lblFont) diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index f2922b24..2b536b8a 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -55,7 +55,6 @@ from novelwriter.dialogs.wordlist import GuiWordList from novelwriter.dialogs.preferences import GuiPreferences from novelwriter.dialogs.projdetails import GuiProjectDetails from novelwriter.dialogs.projsettings import GuiProjectSettings -from novelwriter.tools.build import GuiBuildNovel from novelwriter.tools.lipsum import GuiLipsum from novelwriter.tools.manuscript import GuiManuscript from novelwriter.tools.projwizard import GuiProjectWizard @@ -1010,26 +1009,6 @@ class GuiMain(QMainWindow): return True - def showBuildProjectDialog(self): - """Open the build project dialog. - """ - if not self.hasProject: - logger.error("No project open") - return False - - dlgBuild = getGuiItem("GuiBuildNovel") - if dlgBuild is None: - dlgBuild = GuiBuildNovel(self) - assert isinstance(dlgBuild, GuiBuildNovel) - - dlgBuild.setModal(False) - dlgBuild.show() - dlgBuild.raise_() - qApp.processEvents() - dlgBuild.viewCachedDoc() - - return True - @pyqtSlot() def showBuildManuscriptDialog(self): """Open the build manuscript dialog. diff --git a/novelwriter/tools/build.py b/novelwriter/tools/build.py deleted file mode 100644 index c013c7b8..00000000 --- a/novelwriter/tools/build.py +++ /dev/null @@ -1,1425 +0,0 @@ -""" -novelWriter – GUI Build Novel Project -===================================== -GUI classes for the build novel project dialog - -File History: -Created: 2020-05-09 [0.5] - -This file is a part of novelWriter -Copyright 2018–2023, 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 . -""" - -import json -import logging - -from time import time -from pathlib import Path -from datetime import datetime - -from PyQt5.QtGui import ( - QPalette, QColor, QFont, QCursor, QFontInfo -) -from PyQt5.QtCore import Qt, QTimer -from PyQt5.QtWidgets import ( - qApp, QDialog, QVBoxLayout, QHBoxLayout, QTextBrowser, QPushButton, QLabel, - QLineEdit, QGroupBox, QGridLayout, QProgressBar, QMenu, QAction, - QFileDialog, QFontDialog, QSpinBox, QScrollArea, QSplitter, QWidget, - QSizePolicy, QDoubleSpinBox, QComboBox -) -from PyQt5.QtPrintSupport import QPrinter, QPrintPreviewDialog - -from novelwriter import CONFIG -from novelwriter.enum import nwAlert, nwItemType, nwItemLayout, nwItemClass -from novelwriter.error import formatException, logException -from novelwriter.common import fuzzyTime, makeFileNameSafe -from novelwriter.constants import nwConst, nwFiles -from novelwriter.core.tomd import ToMarkdown -from novelwriter.core.toodt import ToOdt -from novelwriter.core.tohtml import ToHtml -from novelwriter.extensions.switch import NSwitch - -logger = logging.getLogger(__name__) - - -class GuiBuildNovel(QDialog): - - FMT_PDF = 1 # Print to PDF - FMT_ODT = 2 # Open Document file - FMT_FODT = 3 # Flat Open Document file - FMT_HTM = 4 # HTML5 - FMT_NWD = 5 # nW Markdown - FMT_MD = 6 # Standard Markdown - FMT_GH = 7 # GitHub Markdown - FMT_JSON_H = 8 # HTML5 wrapped in JSON - FMT_JSON_M = 9 # nW Markdown wrapped in JSON - - def __init__(self, mainGui): - super().__init__(parent=mainGui) - - logger.debug("Initialising GuiBuildNovel ...") - self.setObjectName("GuiBuildNovel") - - self.mainGui = mainGui - self.mainTheme = mainGui.mainTheme - self.theProject = mainGui.theProject - - self.htmlText = [] # List of html documents - self.htmlStyle = [] # List of html styles - self.htmlSize = 0 # Size of the html document - self.buildTime = 0 # The timestamp of the last build - - self.setWindowTitle(self.tr("Build Novel Project")) - self.setMinimumWidth(CONFIG.pxInt(700)) - self.setMinimumHeight(CONFIG.pxInt(600)) - - pOptions = self.theProject.options - self.resize( - CONFIG.pxInt(pOptions.getInt("GuiBuildNovel", "winWidth", 900)), - CONFIG.pxInt(pOptions.getInt("GuiBuildNovel", "winHeight", 800)) - ) - - self.docView = GuiBuildNovelDocView(self, self.theProject) - - hS = self.mainTheme.fontPixelSize - wS = 2*hS - - # Title Formats - # ============= - - self.titleGroup = QGroupBox(self.tr("Title Formats for Novel Files"), self) - self.titleForm = QGridLayout(self) - self.titleGroup.setLayout(self.titleForm) - - fmtHelp = "
".join([ - "%s" % self.tr("Formatting Codes:"), - self.tr("{0} for the title as set in the document").format(r"%title%"), - self.tr("{0} for chapter number (1, 2, 3)").format(r"%ch%"), - self.tr("{0} for chapter number as a word (one, two)").format(r"%chw%"), - self.tr("{0} for chapter number in upper case Roman").format(r"%chI%"), - self.tr("{0} for chapter number in lower case Roman").format(r"%chi%"), - self.tr("{0} for scene number within chapter").format(r"%sc%"), - self.tr("{0} for scene number within novel").format(r"%sca%"), - ]) - fmtScHelp = "

%s" % self.tr( - "Leave blank to skip this heading, or set to a static text, like " - "for instance '{0}', to make a separator. The separator will " - "be centred automatically and only appear between sections of " - "the same type." - ).format("* * *") - xFmt = CONFIG.pxInt(100) - - self.fmtTitle = QLineEdit() - self.fmtTitle.setMaxLength(200) - self.fmtTitle.setMinimumWidth(xFmt) - self.fmtTitle.setToolTip(fmtHelp) - self.fmtTitle.setText( - self._reFmtCodes(self.theProject.data.getTitleFormat("title")) - ) - - self.fmtChapter = QLineEdit() - self.fmtChapter.setMaxLength(200) - self.fmtChapter.setMinimumWidth(xFmt) - self.fmtChapter.setToolTip(fmtHelp) - self.fmtChapter.setText( - self._reFmtCodes(self.theProject.data.getTitleFormat("chapter")) - ) - - self.fmtUnnumbered = QLineEdit() - self.fmtUnnumbered.setMaxLength(200) - self.fmtUnnumbered.setMinimumWidth(xFmt) - self.fmtUnnumbered.setToolTip(fmtHelp) - self.fmtUnnumbered.setText( - self._reFmtCodes(self.theProject.data.getTitleFormat("unnumbered")) - ) - - self.fmtScene = QLineEdit() - self.fmtScene.setMaxLength(200) - self.fmtScene.setMinimumWidth(xFmt) - self.fmtScene.setToolTip(fmtHelp + fmtScHelp) - self.fmtScene.setText( - self._reFmtCodes(self.theProject.data.getTitleFormat("scene")) - ) - - self.fmtSection = QLineEdit() - self.fmtSection.setMaxLength(200) - self.fmtSection.setMinimumWidth(xFmt) - self.fmtSection.setToolTip(fmtHelp + fmtScHelp) - self.fmtSection.setText( - self._reFmtCodes(self.theProject.data.getTitleFormat("section")) - ) - - self.buildLang = QComboBox() - self.buildLang.setMinimumWidth(xFmt) - theLangs = CONFIG.listLanguages(CONFIG.LANG_PROJ) - self.buildLang.addItem("[%s]" % self.tr("Not Set"), "None") - for langID, langName in theLangs: - self.buildLang.addItem(langName, langID) - - langIdx = self.buildLang.findData(self.theProject.data.language) - if langIdx != -1: - self.buildLang.setCurrentIndex(langIdx) - - self.hideScene = NSwitch(width=wS, height=hS) - self.hideScene.setChecked( - pOptions.getBool("GuiBuildNovel", "hideScene", False) - ) - - self.hideSection = NSwitch(width=wS, height=hS) - self.hideSection.setChecked( - pOptions.getBool("GuiBuildNovel", "hideSection", True) - ) - - # Wrapper boxes due to QGridView and QLineEdit expand bug - self.boxTitle = QHBoxLayout() - self.boxTitle.addWidget(self.fmtTitle) - self.boxChapter = QHBoxLayout() - self.boxChapter.addWidget(self.fmtChapter) - self.boxUnnumb = QHBoxLayout() - self.boxUnnumb.addWidget(self.fmtUnnumbered) - self.boxScene = QHBoxLayout() - self.boxScene.addWidget(self.fmtScene) - self.boxSection = QHBoxLayout() - self.boxSection.addWidget(self.fmtSection) - - titleLabel = QLabel(self.tr("Title")) - chapterLabel = QLabel(self.tr("Chapter")) - unnumbLabel = QLabel(self.tr("Unnumbered")) - sceneLabel = QLabel(self.tr("Scene")) - sectionLabel = QLabel(self.tr("Section")) - langLabel = QLabel(self.tr("Language")) - hSceneLabel = QLabel(self.tr("Hide scene")) - hSectionLabel = QLabel(self.tr("Hide section")) - - self.titleForm.addWidget(titleLabel, 0, 0, 1, 1, Qt.AlignLeft) - self.titleForm.addLayout(self.boxTitle, 0, 1, 1, 1, Qt.AlignRight) - self.titleForm.addWidget(chapterLabel, 1, 0, 1, 1, Qt.AlignLeft) - self.titleForm.addLayout(self.boxChapter, 1, 1, 1, 1, Qt.AlignRight) - self.titleForm.addWidget(unnumbLabel, 2, 0, 1, 1, Qt.AlignLeft) - self.titleForm.addLayout(self.boxUnnumb, 2, 1, 1, 1, Qt.AlignRight) - self.titleForm.addWidget(sceneLabel, 3, 0, 1, 1, Qt.AlignLeft) - self.titleForm.addLayout(self.boxScene, 3, 1, 1, 1, Qt.AlignRight) - self.titleForm.addWidget(sectionLabel, 4, 0, 1, 1, Qt.AlignLeft) - self.titleForm.addLayout(self.boxSection, 4, 1, 1, 1, Qt.AlignRight) - self.titleForm.addWidget(langLabel, 5, 0, 1, 1, Qt.AlignLeft) - self.titleForm.addWidget(self.buildLang, 5, 1, 1, 1, Qt.AlignRight) - self.titleForm.addWidget(hSceneLabel, 6, 0, 1, 1, Qt.AlignLeft) - self.titleForm.addWidget(self.hideScene, 6, 1, 1, 1, Qt.AlignRight) - self.titleForm.addWidget(hSectionLabel, 7, 0, 1, 1, Qt.AlignLeft) - self.titleForm.addWidget(self.hideSection, 7, 1, 1, 1, Qt.AlignRight) - - self.titleForm.setColumnStretch(0, 0) - self.titleForm.setColumnStretch(1, 1) - - # Font Options - # ============ - - self.fontGroup = QGroupBox(self.tr("Font Options"), self) - self.fontForm = QGridLayout(self) - self.fontGroup.setLayout(self.fontForm) - - # Font Family - self.textFont = QLineEdit() - self.textFont.setReadOnly(True) - self.textFont.setMinimumWidth(xFmt) - self.textFont.setText( - pOptions.getString("GuiBuildNovel", "textFont", CONFIG.textFont) - ) - self.fontButton = QPushButton("...") - self.fontButton.setMaximumWidth(int(2.5*self.mainTheme.getTextWidth("..."))) - self.fontButton.clicked.connect(self._selectFont) - - self.textSize = QSpinBox(self) - self.textSize.setFixedWidth(6*self.mainTheme.textNWidth) - self.textSize.setMinimum(6) - self.textSize.setMaximum(72) - self.textSize.setSingleStep(1) - self.textSize.setValue( - pOptions.getInt("GuiBuildNovel", "textSize", CONFIG.textSize) - ) - - self.lineHeight = QDoubleSpinBox(self) - self.lineHeight.setFixedWidth(6*self.mainTheme.textNWidth) - self.lineHeight.setMinimum(0.8) - self.lineHeight.setMaximum(3.0) - self.lineHeight.setSingleStep(0.05) - self.lineHeight.setDecimals(2) - self.lineHeight.setValue( - pOptions.getFloat("GuiBuildNovel", "lineHeight", 1.15) - ) - - # Wrapper box due to QGridView and QLineEdit expand bug - self.boxFont = QHBoxLayout() - self.boxFont.addWidget(self.textFont) - - fontFamilyLabel = QLabel(self.tr("Font family")) - fontSizeLabel = QLabel(self.tr("Font size")) - lineHeightLabel = QLabel(self.tr("Line height")) - justifyLabel = QLabel(self.tr("Justify text")) - stylingLabel = QLabel(self.tr("Disable styling")) - - self.fontForm.addWidget(fontFamilyLabel, 0, 0, 1, 1, Qt.AlignLeft) - self.fontForm.addLayout(self.boxFont, 0, 1, 1, 1, Qt.AlignRight) - self.fontForm.addWidget(self.fontButton, 0, 2, 1, 1, Qt.AlignRight) - self.fontForm.addWidget(fontSizeLabel, 1, 0, 1, 1, Qt.AlignLeft) - self.fontForm.addWidget(self.textSize, 1, 1, 1, 2, Qt.AlignRight) - self.fontForm.addWidget(lineHeightLabel, 2, 0, 1, 1, Qt.AlignLeft) - self.fontForm.addWidget(self.lineHeight, 2, 1, 1, 2, Qt.AlignRight) - - self.fontForm.setColumnStretch(0, 0) - self.fontForm.setColumnStretch(1, 1) - self.fontForm.setColumnStretch(2, 0) - - # Styling Options - # =============== - - self.styleGroup = QGroupBox(self.tr("Styling Options"), self) - self.styleForm = QGridLayout(self) - self.styleGroup.setLayout(self.styleForm) - - self.justifyText = NSwitch(width=wS, height=hS) - self.justifyText.setChecked( - pOptions.getBool("GuiBuildNovel", "justifyText", False) - ) - - self.noStyling = NSwitch(width=wS, height=hS) - self.noStyling.setChecked( - pOptions.getBool("GuiBuildNovel", "noStyling", False) - ) - - self.styleForm.addWidget(justifyLabel, 1, 0, 1, 1, Qt.AlignLeft) - self.styleForm.addWidget(self.justifyText, 1, 1, 1, 2, Qt.AlignRight) - self.styleForm.addWidget(stylingLabel, 2, 0, 1, 1, Qt.AlignLeft) - self.styleForm.addWidget(self.noStyling, 2, 1, 1, 2, Qt.AlignRight) - - self.styleForm.setColumnStretch(0, 0) - self.styleForm.setColumnStretch(1, 1) - - # Include Options - # =============== - - self.textGroup = QGroupBox(self.tr("Include Options"), self) - self.textForm = QGridLayout(self) - self.textGroup.setLayout(self.textForm) - - self.includeSynopsis = NSwitch(width=wS, height=hS) - self.includeSynopsis.setChecked( - pOptions.getBool("GuiBuildNovel", "incSynopsis", False) - ) - - self.includeComments = NSwitch(width=wS, height=hS) - self.includeComments.setChecked( - pOptions.getBool("GuiBuildNovel", "incComments", False) - ) - - self.includeKeywords = NSwitch(width=wS, height=hS) - self.includeKeywords.setChecked( - pOptions.getBool("GuiBuildNovel", "incKeywords", False) - ) - - self.includeBody = NSwitch(width=wS, height=hS) - self.includeBody.setChecked( - pOptions.getBool("GuiBuildNovel", "incBodyText", True) - ) - - synopsisLabel = QLabel(self.tr("Include synopsis")) - commentsLabel = QLabel(self.tr("Include comments")) - keywordsLabel = QLabel(self.tr("Include keywords")) - bodyLabel = QLabel(self.tr("Include body text")) - - self.textForm.addWidget(synopsisLabel, 0, 0, 1, 1, Qt.AlignLeft) - self.textForm.addWidget(self.includeSynopsis, 0, 1, 1, 1, Qt.AlignRight) - self.textForm.addWidget(commentsLabel, 1, 0, 1, 1, Qt.AlignLeft) - self.textForm.addWidget(self.includeComments, 1, 1, 1, 1, Qt.AlignRight) - self.textForm.addWidget(keywordsLabel, 2, 0, 1, 1, Qt.AlignLeft) - self.textForm.addWidget(self.includeKeywords, 2, 1, 1, 1, Qt.AlignRight) - self.textForm.addWidget(bodyLabel, 3, 0, 1, 1, Qt.AlignLeft) - self.textForm.addWidget(self.includeBody, 3, 1, 1, 1, Qt.AlignRight) - - self.textForm.setColumnStretch(0, 1) - self.textForm.setColumnStretch(1, 0) - - # Root Filter Options - # =================== - - self.rootGroup = QGroupBox(self.tr("Root Filter Options"), self) - self.rootForm = QGridLayout(self) - self.rootGroup.setLayout(self.rootForm) - - rootFilter = pOptions.getValue("GuiBuildNovel", "rootFilter", []) - if not isinstance(rootFilter, list): - rootFilter = [] - - iRow = 0 - self.rootSelection = {} - for tHandle, nwItem in self.theProject.tree.iterRoots(None): - if not nwItem.isInactiveClass(): - rootLabel = QLabel(nwItem.itemName) - rootLabel.setWordWrap(True) - - rootValue = NSwitch(width=wS, height=hS) - rootValue.setChecked(tHandle not in rootFilter) - - self.rootSelection[tHandle] = rootValue - self.rootForm.addWidget(rootLabel, iRow, 0, 1, 1, Qt.AlignLeft) - self.rootForm.addWidget(rootValue, iRow, 1, 1, 1, Qt.AlignRight) - - iRow += 1 - - self.rootForm.setColumnStretch(0, 1) - self.rootForm.setColumnStretch(1, 0) - - # File Filter Options - # =================== - - self.fileGroup = QGroupBox(self.tr("File Filter Options"), self) - self.fileForm = QGridLayout(self) - self.fileGroup.setLayout(self.fileForm) - - self.novelFiles = NSwitch(width=wS, height=hS) - self.novelFiles.setChecked( - pOptions.getBool("GuiBuildNovel", "addNovel", True) - ) - - self.noteFiles = NSwitch(width=wS, height=hS) - self.noteFiles.setChecked( - pOptions.getBool("GuiBuildNovel", "addNotes", False) - ) - - self.ignoreFlag = NSwitch(width=wS, height=hS) - self.ignoreFlag.setChecked( - pOptions.getBool("GuiBuildNovel", "ignoreFlag", False) - ) - - novelLabel = QLabel(self.tr("Include novel files")) - notesLabel = QLabel(self.tr("Include note files")) - activeLabel = QLabel(self.tr("Include inactive files")) - - self.fileForm.addWidget(novelLabel, 0, 0, 1, 1, Qt.AlignLeft) - self.fileForm.addWidget(self.novelFiles, 0, 1, 1, 1, Qt.AlignRight) - self.fileForm.addWidget(notesLabel, 1, 0, 1, 1, Qt.AlignLeft) - self.fileForm.addWidget(self.noteFiles, 1, 1, 1, 1, Qt.AlignRight) - self.fileForm.addWidget(activeLabel, 2, 0, 1, 1, Qt.AlignLeft) - self.fileForm.addWidget(self.ignoreFlag, 2, 1, 1, 1, Qt.AlignRight) - - self.fileForm.setColumnStretch(0, 1) - self.fileForm.setColumnStretch(1, 0) - - # Export Options - # ============== - - self.exportGroup = QGroupBox(self.tr("Export Options"), self) - self.exportForm = QGridLayout(self) - self.exportGroup.setLayout(self.exportForm) - - self.replaceTabs = NSwitch(width=wS, height=hS) - self.replaceTabs.setChecked( - pOptions.getBool("GuiBuildNovel", "replaceTabs", False) - ) - - self.replaceUCode = NSwitch(width=wS, height=hS) - self.replaceUCode.setChecked( - pOptions.getBool("GuiBuildNovel", "replaceUCode", False) - ) - - tabsLabel = QLabel(self.tr("Replace tabs with spaces")) - uCodeLabel = QLabel(self.tr("Replace Unicode in HTML")) - - self.exportForm.addWidget(tabsLabel, 0, 0, 1, 1, Qt.AlignLeft) - self.exportForm.addWidget(self.replaceTabs, 0, 1, 1, 1, Qt.AlignRight) - self.exportForm.addWidget(uCodeLabel, 1, 0, 1, 1, Qt.AlignLeft) - self.exportForm.addWidget(self.replaceUCode, 1, 1, 1, 1, Qt.AlignRight) - - self.exportForm.setColumnStretch(0, 1) - self.exportForm.setColumnStretch(1, 0) - - # Build Button - # ============ - - self.buildProgress = QProgressBar() - - self.buildNovel = QPushButton(self.tr("Build Preview")) - self.buildNovel.clicked.connect(self._buildPreview) - - # Action Buttons - # ============== - - self.buttonBox = QHBoxLayout() - - # Printing - - self.printMenu = QMenu(self) - self.btnPrint = QPushButton(self.tr("Print")) - self.btnPrint.setMenu(self.printMenu) - - self.printSend = QAction(self.tr("Print Preview"), self) - self.printSend.triggered.connect(self._printDocument) - self.printMenu.addAction(self.printSend) - - self.printFile = QAction(self.tr("Print to PDF"), self) - self.printFile.triggered.connect(lambda: self._saveDocument(self.FMT_PDF)) - self.printMenu.addAction(self.printFile) - - # Saving to File - - self.saveMenu = QMenu(self) - self.btnSave = QPushButton(self.tr("Save As")) - self.btnSave.setMenu(self.saveMenu) - - self.saveODT = QAction(self.tr("Open Document (.odt)"), self) - self.saveODT.triggered.connect(lambda: self._saveDocument(self.FMT_ODT)) - self.saveMenu.addAction(self.saveODT) - - self.saveFODT = QAction(self.tr("Flat Open Document (.fodt)"), self) - self.saveFODT.triggered.connect(lambda: self._saveDocument(self.FMT_FODT)) - self.saveMenu.addAction(self.saveFODT) - - self.saveHTM = QAction(self.tr("novelWriter HTML (.htm)"), self) - self.saveHTM.triggered.connect(lambda: self._saveDocument(self.FMT_HTM)) - self.saveMenu.addAction(self.saveHTM) - - self.saveNWD = QAction(self.tr("novelWriter Markdown (.nwd)"), self) - self.saveNWD.triggered.connect(lambda: self._saveDocument(self.FMT_NWD)) - self.saveMenu.addAction(self.saveNWD) - - self.saveMD = QAction(self.tr("Standard Markdown (.md)"), self) - self.saveMD.triggered.connect(lambda: self._saveDocument(self.FMT_MD)) - self.saveMenu.addAction(self.saveMD) - - self.saveGH = QAction(self.tr("GitHub Markdown (.md)"), self) - self.saveGH.triggered.connect(lambda: self._saveDocument(self.FMT_GH)) - self.saveMenu.addAction(self.saveGH) - - self.saveJsonH = QAction(self.tr("JSON + novelWriter HTML (.json)"), self) - self.saveJsonH.triggered.connect(lambda: self._saveDocument(self.FMT_JSON_H)) - self.saveMenu.addAction(self.saveJsonH) - - self.saveJsonM = QAction(self.tr("JSON + novelWriter Markdown (.json)"), self) - self.saveJsonM.triggered.connect(lambda: self._saveDocument(self.FMT_JSON_M)) - self.saveMenu.addAction(self.saveJsonM) - - self.btnClose = QPushButton(self.tr("Close")) - self.btnClose.clicked.connect(self._doClose) - - self.buttonBox.addWidget(self.btnSave) - self.buttonBox.addWidget(self.btnPrint) - self.buttonBox.addWidget(self.btnClose) - self.buttonBox.setSpacing(CONFIG.pxInt(4)) - - # Assemble GUI - # ============ - - # Splitter Position - boxWidth = CONFIG.pxInt(350) - boxWidth = pOptions.getInt("GuiBuildNovel", "boxWidth", boxWidth) - docWidth = max(self.width() - boxWidth, 100) - docWidth = pOptions.getInt("GuiBuildNovel", "docWidth", docWidth) - - # The Tool Box - self.toolsBox = QVBoxLayout() - self.toolsBox.addWidget(self.titleGroup) - self.toolsBox.addWidget(self.fontGroup) - self.toolsBox.addWidget(self.styleGroup) - self.toolsBox.addWidget(self.textGroup) - self.toolsBox.addWidget(self.rootGroup) - self.toolsBox.addWidget(self.fileGroup) - self.toolsBox.addWidget(self.exportGroup) - self.toolsBox.addStretch(1) - - # Tool Box Wrapper Widget - self.toolsWidget = QWidget() - self.toolsWidget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum) - self.toolsWidget.setLayout(self.toolsBox) - - # Tool Box Scroll Area - self.toolsArea = QScrollArea() - self.toolsArea.setMinimumWidth(CONFIG.pxInt(250)) - self.toolsArea.setWidgetResizable(True) - self.toolsArea.setWidget(self.toolsWidget) - - if CONFIG.hideVScroll: - self.toolsArea.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - else: - self.toolsArea.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) - - if CONFIG.hideHScroll: - self.toolsArea.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - else: - self.toolsArea.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) - - # Tools and Buttons Layout - tSp = CONFIG.pxInt(8) - self.innerBox = QVBoxLayout() - self.innerBox.addWidget(self.toolsArea) - self.innerBox.addSpacing(tSp) - self.innerBox.addWidget(self.buildProgress) - self.innerBox.addWidget(self.buildNovel) - self.innerBox.addSpacing(tSp) - self.innerBox.addLayout(self.buttonBox) - - # Tools and Buttons Wrapper Widget - self.innerWidget = QWidget() - self.innerWidget.setLayout(self.innerBox) - - # Main Dialog Splitter - self.mainSplit = QSplitter(Qt.Horizontal) - self.mainSplit.addWidget(self.innerWidget) - self.mainSplit.addWidget(self.docView) - self.mainSplit.setSizes([boxWidth, docWidth]) - - self.idxSettings = self.mainSplit.indexOf(self.innerWidget) - self.idxDocument = self.mainSplit.indexOf(self.docView) - - self.mainSplit.setCollapsible(self.idxSettings, False) - self.mainSplit.setCollapsible(self.idxDocument, False) - - # Outer Layout - self.outerBox = QHBoxLayout() - self.outerBox.addWidget(self.mainSplit) - - self.setLayout(self.outerBox) - self.buildNovel.setFocus() - - logger.debug("GuiBuildNovel initialisation complete") - - return - - def viewCachedDoc(self): - """Load the previously generated document from cache. - """ - if self._loadCache(): - textFont = self.textFont.text() - textSize = self.textSize.value() - justifyText = self.justifyText.isChecked() - self.docView.setTextFont(textFont, textSize) - self.docView.setJustify(justifyText) - if self.noStyling.isChecked(): - self.docView.clearStyleSheet() - else: - self.docView.setStyleSheet(self.htmlStyle) - - htmlSize = sum([len(x) for x in self.htmlText]) - if htmlSize < nwConst.MAX_BUILDSIZE: - qApp.processEvents() - self.docView.setContent(self.htmlText, self.buildTime) - else: - self.docView.setText( - self.tr("Failed to generate preview. The result is too big.") - ) - - else: - self.htmlText = [] - self.htmlStyle = [] - self.buildTime = 0 - return False - - return True - - ## - # Slots and Related - ## - - def _buildPreview(self): - """Build a preview of the project in the document viewer. - """ - # Get Settings - justifyText = self.justifyText.isChecked() - noStyling = self.noStyling.isChecked() - textFont = self.textFont.text() - textSize = self.textSize.value() - replaceTabs = self.replaceTabs.isChecked() - - self.htmlText = [] - self.htmlStyle = [] - self.htmlSize = 0 - - # Build Preview - # ============= - - makeHtml = ToHtml(self.theProject) - self._doBuild(makeHtml, isPreview=True) - if replaceTabs: - makeHtml.replaceTabs() - - self.htmlText = makeHtml.fullHTML - self.htmlStyle = makeHtml.getStyleSheet() - self.htmlSize = makeHtml.getFullResultSize() - self.buildTime = int(time()) - - # Load Preview - # ============ - - self.docView.setTextFont(textFont, textSize) - self.docView.setJustify(justifyText) - if noStyling: - self.docView.clearStyleSheet() - else: - self.docView.setStyleSheet(self.htmlStyle) - - if self.htmlSize < nwConst.MAX_BUILDSIZE: - self.docView.setContent(self.htmlText, self.buildTime) - else: - self.docView.setText( - "Failed to generate preview. The result is too big." - ) - - self._saveCache() - - return - - def _doBuild(self, bldObj, isPreview=False, doConvert=True): - """Run the build with a specific build object. - """ - tStart = int(time()) - - # Get Settings - fmtTitle = self.fmtTitle.text() - fmtChapter = self.fmtChapter.text() - fmtUnnumbered = self.fmtUnnumbered.text() - fmtScene = self.fmtScene.text() - fmtSection = self.fmtSection.text() - buildLang = self.buildLang.currentData() - hideScene = self.hideScene.isChecked() - hideSection = self.hideSection.isChecked() - textFont = self.textFont.text() - textSize = self.textSize.value() - lineHeight = self.lineHeight.value() - justifyText = self.justifyText.isChecked() - noStyling = self.noStyling.isChecked() - incSynopsis = self.includeSynopsis.isChecked() - incComments = self.includeComments.isChecked() - incKeywords = self.includeKeywords.isChecked() - novelFiles = self.novelFiles.isChecked() - noteFiles = self.noteFiles.isChecked() - ignoreFlag = self.ignoreFlag.isChecked() - includeBody = self.includeBody.isChecked() - replaceUCode = self.replaceUCode.isChecked() - - # The language lookup dict is reloaded if needed - self.theProject.setProjectLang(buildLang) - - # Get font information - fontInfo = QFontInfo(QFont(textFont, textSize)) - textFixed = fontInfo.fixedPitch() - - isHtml = isinstance(bldObj, ToHtml) - isOdt = isinstance(bldObj, ToOdt) - - bldObj.setTitleFormat(fmtTitle) - bldObj.setChapterFormat(fmtChapter) - bldObj.setUnNumberedFormat(fmtUnnumbered) - bldObj.setSceneFormat(fmtScene, hideScene) - bldObj.setSectionFormat(fmtSection, hideSection) - - bldObj.setFont(textFont, textSize, textFixed) - bldObj.setJustify(justifyText) - bldObj.setLineHeight(lineHeight) - - bldObj.setSynopsis(incSynopsis) - bldObj.setComments(incComments) - bldObj.setKeywords(incKeywords) - bldObj.setBodyText(includeBody) - - if isHtml: - bldObj.setStyles(not noStyling) - bldObj.setReplaceUnicode(replaceUCode) - - if isOdt: - bldObj.setColourHeaders(not noStyling) - bldObj.setLanguage(buildLang) - bldObj.initDocument() - - # Make sure the project and document is up to date - self.mainGui.saveDocument() - - self.buildProgress.setMaximum(len(self.theProject.tree)) - self.buildProgress.setValue(0) - - rootFilter = set(self._generateRootFilter()) - for nItt, tItem in enumerate(self.theProject.tree): - - noteRoot = noteFiles - noteRoot &= tItem.itemType == nwItemType.ROOT - noteRoot &= tItem.itemClass != nwItemClass.NOVEL - noteRoot &= tItem.itemClass != nwItemClass.ARCHIVE - - try: - if noteRoot: - # Add headers for root folders of notes - bldObj.addRootHeading(tItem.itemHandle) - if doConvert: - bldObj.doConvert() - - elif self._checkInclude(tItem, noteFiles, novelFiles, ignoreFlag, rootFilter): - bldObj.setText(tItem.itemHandle) - bldObj.doPreProcessing() - bldObj.tokenizeText() - bldObj.doHeaders() - if doConvert: - bldObj.doConvert() - - except Exception: - logger.error("Failed to build document '%s'", tItem.itemHandle) - logException() - if isPreview: - self.docView.setText(( - "Failed to generate preview. " - "Document with title '%s' could not be parsed." - ) % tItem.itemName) - - return False - - # Update progress bar, also for skipped items - self.buildProgress.setValue(nItt+1) - - if isOdt: - bldObj.closeDocument() - - tEnd = int(time()) - logger.debug("Built project in %.3f ms", 1000*(tEnd - tStart)) - - if bldObj.errData: - self.mainGui.makeAlert([ - self.tr("There were problems when building the project:") - ] + bldObj.errData, nwAlert.ERROR) - - return - - def _checkInclude(self, theItem, noteFiles, novelFiles, ignoreFlag, rootFilter): - """This function checks whether a file should be included in the - export or not. For standard note and novel files, this is - controlled by the options selected by the user. For other files - classified as non-exportable, a few checks must be made, and the - following are not: - * Items that are not actual files. - * Items that have been orphaned which are tagged as NO_LAYOUT - and NO_CLASS. - * Items that appear in the TRASH folder or have parent set to - None (orphaned files). - """ - if theItem is None: - return False - - if not (theItem.isActive or ignoreFlag): - return False - - if theItem.itemRoot in rootFilter: - return False - - isNone = not theItem.isFileType() - isNone |= theItem.itemLayout == nwItemLayout.NO_LAYOUT - isNone |= theItem.isInactiveClass() - isNone |= theItem.itemParent is None - isNote = theItem.isNoteLayout() - isNovel = not isNone and not isNote - - if isNone: - return False - if isNote and not noteFiles: - return False - if isNovel and not novelFiles: - return False - - return True - - def _saveDocument(self, theFmt): - """Save the document to various formats. - """ - replaceTabs = self.replaceTabs.isChecked() - - fileExt = "" - textFmt = "" - - # Settings - # ======== - - if theFmt == self.FMT_ODT: - fileExt = "odt" - textFmt = self.tr("Open Document") - - elif theFmt == self.FMT_FODT: - fileExt = "fodt" - textFmt = self.tr("Flat Open Document") - - elif theFmt == self.FMT_HTM: - fileExt = "htm" - textFmt = self.tr("Plain HTML") - - elif theFmt == self.FMT_NWD: - fileExt = "nwd" - textFmt = self.tr("novelWriter Markdown") - - elif theFmt == self.FMT_MD: - fileExt = "md" - textFmt = self.tr("Standard Markdown") - - elif theFmt == self.FMT_GH: - fileExt = "md" - textFmt = self.tr("GitHub Markdown") - - elif theFmt == self.FMT_JSON_H: - fileExt = "json" - textFmt = self.tr("JSON + novelWriter HTML") - - elif theFmt == self.FMT_JSON_M: - fileExt = "json" - textFmt = self.tr("JSON + novelWriter Markdown") - - elif theFmt == self.FMT_PDF: - fileExt = "pdf" - textFmt = self.tr("PDF") - - else: - return False - - # Generate File Name - # ================== - - cleanName = makeFileNameSafe(self.theProject.data.name) - fileName = "%s.%s" % (cleanName, fileExt) - savePath = CONFIG.lastPath() / fileName - savePath, _ = QFileDialog.getSaveFileName( - self, self.tr("Save Document As"), str(savePath) - ) - if not savePath: - return False - - CONFIG.setLastPath(savePath) - - # Build and Write - # =============== - - errMsg = "" - wSuccess = False - - if theFmt == self.FMT_ODT: - makeOdt = ToOdt(self.theProject, isFlat=False) - self._doBuild(makeOdt) - try: - makeOdt.saveOpenDocText(savePath) - wSuccess = True - except Exception as exc: - errMsg = formatException(exc) - - elif theFmt == self.FMT_FODT: - makeOdt = ToOdt(self.theProject, isFlat=True) - self._doBuild(makeOdt) - try: - makeOdt.saveFlatXML(savePath) - wSuccess = True - except Exception as exc: - errMsg = formatException(exc) - - elif theFmt == self.FMT_HTM: - makeHtml = ToHtml(self.theProject) - self._doBuild(makeHtml) - if replaceTabs: - makeHtml.replaceTabs() - - try: - makeHtml.saveHTML5(savePath) - wSuccess = True - except Exception as exc: - errMsg = formatException(exc) - - elif theFmt == self.FMT_NWD: - makeNwd = ToMarkdown(self.theProject) - makeNwd.setKeepMarkdown(True) - self._doBuild(makeNwd, doConvert=False) - if replaceTabs: - makeNwd.replaceTabs(spaceChar=" ") - - try: - makeNwd.saveRawMarkdown(savePath) - wSuccess = True - except Exception as exc: - errMsg = formatException(exc) - - elif theFmt in (self.FMT_MD, self.FMT_GH): - makeMd = ToMarkdown(self.theProject) - if theFmt == self.FMT_GH: - makeMd.setGitHubMarkdown() - else: - makeMd.setStandardMarkdown() - - self._doBuild(makeMd) - if replaceTabs: - makeMd.replaceTabs(nSpaces=4, spaceChar=" ") - - try: - makeMd.saveMarkdown(savePath) - wSuccess = True - except Exception as exc: - errMsg = formatException(exc) - - elif theFmt == self.FMT_JSON_H or theFmt == self.FMT_JSON_M: - jsonData = { - "meta": { - "projectName": self.theProject.data.name, - "novelTitle": self.theProject.data.title, - "novelAuthor": self.theProject.data.author, - "buildTime": self.buildTime, - } - } - - if theFmt == self.FMT_JSON_H: - makeHtml = ToHtml(self.theProject) - self._doBuild(makeHtml) - if replaceTabs: - makeHtml.replaceTabs() - - theBody = [] - for htmlPage in makeHtml.fullHTML: - theBody.append(htmlPage.rstrip("\n").split("\n")) - jsonData["text"] = { - "css": self.htmlStyle, - "html": theBody, - } - - elif theFmt == self.FMT_JSON_M: - makeMd = ToHtml(self.theProject) - makeMd.setKeepMarkdown(True) - self._doBuild(makeMd, doConvert=False) - if replaceTabs: - makeMd.replaceTabs(spaceChar=" ") - - theBody = [] - for nwdPage in makeMd.theMarkdown: - theBody.append(nwdPage.split("\n")) - jsonData["text"] = { - "nwd": theBody, - } - - try: - with open(savePath, mode="w", encoding="utf-8") as outFile: - outFile.write(json.dumps(jsonData, indent=2)) - wSuccess = True - except Exception as exc: - errMsg = formatException(exc) - - elif theFmt == self.FMT_PDF: - try: - thePrinter = QPrinter() - thePrinter.setOutputFormat(QPrinter.PdfFormat) - thePrinter.setOrientation(QPrinter.Portrait) - thePrinter.setDuplex(QPrinter.DuplexLongSide) - thePrinter.setFontEmbeddingEnabled(True) - thePrinter.setColorMode(QPrinter.Color) - thePrinter.setOutputFileName(savePath) - self.docView.document().print(thePrinter) - wSuccess = True - - except Exception as exc: - errMsg = formatException(exc) - - else: - # If the if statements above and here match, it should not - # be possible to reach this else statement. - return False # pragma: no cover - - # Report to User - # ============== - - if wSuccess: - self.mainGui.makeAlert([ - self.tr("{0} file successfully written to:").format(textFmt), savePath - ], nwAlert.INFO) - else: - self.mainGui.makeAlert(self.tr( - "Failed to write {0} file. {1}" - ).format(textFmt, errMsg), nwAlert.ERROR) - - return wSuccess - - def _printDocument(self): - """Open the print preview dialog. - """ - thePreview = QPrintPreviewDialog(self) - thePreview.paintRequested.connect(self._doPrintPreview) - thePreview.exec_() - return - - def _doPrintPreview(self, thePrinter): - """Connect the print preview painter to the document viewer. - """ - qApp.setOverrideCursor(QCursor(Qt.WaitCursor)) - thePrinter.setOrientation(QPrinter.Portrait) - self.docView.document().print(thePrinter) - qApp.restoreOverrideCursor() - return - - def _selectFont(self): - """Open the QFontDialog and set a font for the font style. - """ - currFont = QFont() - currFont.setFamily(self.textFont.text()) - currFont.setPointSize(self.textSize.value()) - theFont, theStatus = QFontDialog.getFont(currFont, self) - if theStatus: - self.textFont.setText(theFont.family()) - self.textSize.setValue(theFont.pointSize()) - - self.raise_() # Move the dialog to front (fixes a bug on macOS) - - return - - def _generateRootFilter(self): - """Return a list of all root folders that are filtered out. - """ - return [h for h, s in self.rootSelection.items() if not s.isChecked()] - - def _loadCache(self): - """Save the current data to cache. - """ - buildCache = self.theProject.storage.getCacheFile(nwFiles.BUILD_CACHE) - if not isinstance(buildCache, Path): - return False - - dataCount = 0 - if buildCache.exists(): - logger.debug("Loading build cache") - try: - with open(buildCache, mode="r", encoding="utf-8") as inFile: - theJson = inFile.read() - theData = json.loads(theJson) - except Exception: - logger.error("Failed to load build cache") - logException() - return False - - if "buildTime" in theData.keys(): - self.buildTime = theData["buildTime"] - if "htmlStyle" in theData.keys(): - self.htmlStyle = theData["htmlStyle"] - dataCount += 1 - if "htmlText" in theData.keys(): - self.htmlText = theData["htmlText"] - dataCount += 1 - - return dataCount == 2 - - def _saveCache(self): - """Save the current data to cache. - """ - buildCache = self.theProject.storage.getCacheFile(nwFiles.BUILD_CACHE) - if not isinstance(buildCache, Path): - return False - - logger.debug("Saving build cache") - try: - with open(buildCache, mode="w+", encoding="utf-8") as outFile: - outFile.write(json.dumps({ - "buildTime": self.buildTime, - "htmlStyle": self.htmlStyle, - "htmlText": self.htmlText, - }, indent=2)) - except Exception: - logger.error("Failed to save build cache") - logException() - return False - - return True - - def _doClose(self): - """Close button was clicked. - """ - self.close() - return - - ## - # Events - ## - - def closeEvent(self, theEvent): - """Capture the user closing the window so we can save settings. - """ - self._saveSettings() - self.docView.clear() - theEvent.accept() - return - - ## - # Internal Functions - ## - - def _saveSettings(self): - """Save the various user settings. - """ - logger.debug("Saving GuiBuildNovel settings") - - # Formatting - self.theProject.data.setTitleFormat({ - "title": self.fmtTitle.text().strip(), - "chapter": self.fmtChapter.text().strip(), - "unnumbered": self.fmtUnnumbered.text().strip(), - "scene": self.fmtScene.text().strip(), - "section": self.fmtSection.text().strip(), - }) - - buildLang = self.buildLang.currentData() - hideScene = self.hideScene.isChecked() - hideSection = self.hideSection.isChecked() - winWidth = CONFIG.rpxInt(self.width()) - winHeight = CONFIG.rpxInt(self.height()) - justifyText = self.justifyText.isChecked() - noStyling = self.noStyling.isChecked() - textFont = self.textFont.text() - textSize = self.textSize.value() - lineHeight = self.lineHeight.value() - novelFiles = self.novelFiles.isChecked() - noteFiles = self.noteFiles.isChecked() - ignoreFlag = self.ignoreFlag.isChecked() - incSynopsis = self.includeSynopsis.isChecked() - incComments = self.includeComments.isChecked() - incKeywords = self.includeKeywords.isChecked() - incBodyText = self.includeBody.isChecked() - replaceTabs = self.replaceTabs.isChecked() - replaceUCode = self.replaceUCode.isChecked() - rootFilter = self._generateRootFilter() - - mainSplit = self.mainSplit.sizes() - boxWidth = CONFIG.rpxInt(mainSplit[0]) - docWidth = CONFIG.rpxInt(mainSplit[1]) - - self.theProject.setProjectLang(buildLang) - - # GUI Settings - pOptions = self.theProject.options - pOptions.setValue("GuiBuildNovel", "hideScene", hideScene) - pOptions.setValue("GuiBuildNovel", "hideSection", hideSection) - pOptions.setValue("GuiBuildNovel", "winWidth", winWidth) - pOptions.setValue("GuiBuildNovel", "winHeight", winHeight) - pOptions.setValue("GuiBuildNovel", "boxWidth", boxWidth) - pOptions.setValue("GuiBuildNovel", "docWidth", docWidth) - pOptions.setValue("GuiBuildNovel", "justifyText", justifyText) - pOptions.setValue("GuiBuildNovel", "noStyling", noStyling) - pOptions.setValue("GuiBuildNovel", "textFont", textFont) - pOptions.setValue("GuiBuildNovel", "textSize", textSize) - pOptions.setValue("GuiBuildNovel", "lineHeight", lineHeight) - pOptions.setValue("GuiBuildNovel", "addNovel", novelFiles) - pOptions.setValue("GuiBuildNovel", "addNotes", noteFiles) - pOptions.setValue("GuiBuildNovel", "ignoreFlag", ignoreFlag) - pOptions.setValue("GuiBuildNovel", "incSynopsis", incSynopsis) - pOptions.setValue("GuiBuildNovel", "incComments", incComments) - pOptions.setValue("GuiBuildNovel", "incKeywords", incKeywords) - pOptions.setValue("GuiBuildNovel", "incBodyText", incBodyText) - pOptions.setValue("GuiBuildNovel", "replaceTabs", replaceTabs) - pOptions.setValue("GuiBuildNovel", "replaceUCode", replaceUCode) - pOptions.setValue("GuiBuildNovel", "rootFilter", rootFilter) - pOptions.saveSettings() - - return - - def _reFmtCodes(self, theFormat): - """Translates old formatting codes to new ones. - """ - theFormat = theFormat.replace(r"%chnum%", r"%ch%") - theFormat = theFormat.replace(r"%scnum%", r"%sc%") - theFormat = theFormat.replace(r"%scabsnum%", r"%sca%") - theFormat = theFormat.replace(r"%chnumword%", r"%chw%") - return theFormat - -# END Class GuiBuildNovel - - -class GuiBuildNovelDocView(QTextBrowser): - - def __init__(self, mainGui, theProject): - super().__init__(parent=mainGui) - - logger.debug("Initialising GuiBuildNovelDocView ...") - - self.theProject = theProject - self.mainGui = mainGui - self.mainTheme = mainGui.mainTheme - self.buildTime = 0 - - self.setMinimumWidth(40*self.mainGui.mainTheme.textNWidth) - self.setOpenExternalLinks(False) - - self.document().setDocumentMargin(CONFIG.getTextMargin()) - self.setPlaceholderText(self.tr( - "This area will show the content of the document to be " - "exported or printed. Press the \"Build Preview\" button " - "to generate content." - )) - - theFont = QFont() - theFont.setFamily(CONFIG.textFont) - theFont.setPointSize(CONFIG.textSize) - self.setFont(theFont) - - # Set the tab stops - self.setTabStopDistance(CONFIG.getTabWidth()) - - docPalette = self.palette() - docPalette.setColor(QPalette.Base, QColor(255, 255, 255)) - docPalette.setColor(QPalette.Text, QColor(0, 0, 0)) - self.setPalette(docPalette) - - lblPalette = self.palette() - lblPalette.setColor(QPalette.Background, lblPalette.toolTipBase().color()) - lblPalette.setColor(QPalette.Foreground, lblPalette.toolTipText().color()) - - lblFont = self.font() - lblFont.setPointSizeF(0.9*self.mainTheme.fontPointSize) - - fPx = int(1.1*self.mainTheme.fontPixelSize) - - self.theTitle = QLabel("", self) - self.theTitle.setIndent(0) - self.theTitle.setAutoFillBackground(True) - self.theTitle.setAlignment(Qt.AlignCenter) - self.theTitle.setFixedHeight(fPx) - self.theTitle.setPalette(lblPalette) - self.theTitle.setFont(lblFont) - - self._updateDocMargins() - self._updateBuildAge() - self.setStyleSheet() - - # Age Timer - self.ageTimer = QTimer() - self.ageTimer.setInterval(10000) - self.ageTimer.timeout.connect(self._updateBuildAge) - self.ageTimer.start() - - logger.debug("GuiBuildNovelDocView initialisation complete") - - return - - def setJustify(self, doJustify): - """Set the justify text option. - """ - theOpt = self.document().defaultTextOption() - if doJustify: - theOpt.setAlignment(Qt.AlignJustify) - else: - theOpt.setAlignment(Qt.AlignAbsolute) - self.document().setDefaultTextOption(theOpt) - return - - def setTextFont(self, textFont, textSize): - """Set the text font properties. - """ - theFont = QFont() - theFont.setFamily(textFont) - theFont.setPointSize(textSize) - self.setFont(theFont) - return - - def setContent(self, theText, timeStamp): - """Set the content, either from text or list of text. - """ - if isinstance(theText, list): - theText = "".join(theText) - - self.buildTime = timeStamp - sPos = self.verticalScrollBar().value() - qApp.setOverrideCursor(QCursor(Qt.WaitCursor)) - - theText = theText.replace("\t", "!!tab!!") - theText = theText.replace("", "") - theText = theText.replace("", "") - self.setHtml(theText) - qApp.processEvents() - - while self.find("!!tab!!"): - theCursor = self.textCursor() - theCursor.insertText("\t") - - self.verticalScrollBar().setValue(sPos) - self._updateBuildAge() - - # Since we change the content while it may still be rendering, we mark - # the document dirty again to make sure it's re-rendered properly. - self.document().markContentsDirty(0, self.document().characterCount()) - qApp.restoreOverrideCursor() - - return - - def setStyleSheet(self, theStyles=None): - """Set the stylesheet for the preview document. - """ - if theStyles is None: - theStyles = [] - - if not theStyles: - theStyles.append("h1, h2 {color: rgb(66, 113, 174);}") - theStyles.append("h3, h4 {color: rgb(50, 50, 50);}") - theStyles.append("a {color: rgb(66, 113, 174);}") - theStyles.append(".tags {color: rgb(245, 135, 31); font-weight: bold;}") - - self.document().setDefaultStyleSheet("\n".join(theStyles)) - - return - - def clearStyleSheet(self): - """Clears the document stylesheet. - """ - self.document().setDefaultStyleSheet("") - return - - ## - # Events - ## - - def resizeEvent(self, theEvent): - """Make sure the document title is the same width as the window. - """ - super().resizeEvent(theEvent) - self._updateDocMargins() - return - - ## - # Internal Functions - ## - - def _updateBuildAge(self): - """Update the build time and the fuzzy age. - """ - if self.buildTime > 0: - strBuildTime = "%s (%s)" % ( - datetime.fromtimestamp(self.buildTime).strftime("%x %X"), - fuzzyTime(time() - self.buildTime) - ) - else: - strBuildTime = self.tr("Unknown") - - self.theTitle.setText("%s %s" % (self.tr("Build Time:"), strBuildTime)) - - return - - def _updateDocMargins(self): - """Automatically adjust the header to fill the top of the - document within the viewport. - """ - vBar = self.verticalScrollBar() - if vBar.isVisible(): - sW = vBar.width() - else: - sW = 0 - - tB = self.frameWidth() - tW = self.width() - 2*tB - sW - tH = self.theTitle.height() - - self.theTitle.setGeometry(tB, tB, tW, tH) - self.setViewportMargins(0, tH, 0, 0) - - return - -# END Class GuiBuildNovelDocView diff --git a/tests/test_core/test_core_options.py b/tests/test_core/test_core_options.py index 1a9cf381..ce26dbe1 100644 --- a/tests/test_core/test_core_options.py +++ b/tests/test_core/test_core_options.py @@ -32,8 +32,7 @@ from novelwriter.gui.noveltree import NovelTreeColumn @pytest.mark.core def testCoreOptions_LoadSave(monkeypatch, mockGUI, fncPath): - """Test loading and saving from the OptionState class. - """ + """Test loading and saving from the OptionState class.""" theProject = NWProject(mockGUI) theOpts = OptionState(theProject) @@ -43,13 +42,12 @@ def testCoreOptions_LoadSave(monkeypatch, mockGUI, fncPath): # Write a test file optFile = metaDir / nwFiles.OPTS_FILE optFile.write_text(json.dumps({ - "GuiBuildNovel": { - "winWidth": 1000, - "winHeight": 700, - "addNovel": True, - "addNotes": False, - "textFont": "Cantarell", - "mockItem": None, + "GuiProjectSettings": { + "winWidth": 570, + "winHeight": 375, + "replaceColW": 130, + "statusColW": 130, + "importColW": 130 }, "MockGroup": { "mockItem": None, @@ -76,12 +74,12 @@ def testCoreOptions_LoadSave(monkeypatch, mockGUI, fncPath): # Check that unwanted items have been removed assert theOpts._theState == { - "GuiBuildNovel": { - "winWidth": 1000, - "winHeight": 700, - "addNovel": True, - "addNotes": False, - "textFont": "Cantarell", + "GuiProjectSettings": { + "winWidth": 570, + "winHeight": 375, + "replaceColW": 130, + "statusColW": 130, + "importColW": 130 }, } @@ -91,12 +89,12 @@ def testCoreOptions_LoadSave(monkeypatch, mockGUI, fncPath): # Load again to check we get the values back assert theOpts.loadSettings() assert theOpts._theState == { - "GuiBuildNovel": { - "winWidth": 1000, - "winHeight": 700, - "addNovel": True, - "addNotes": False, - "textFont": "Cantarell", + "GuiProjectSettings": { + "winWidth": 570, + "winHeight": 375, + "replaceColW": 130, + "statusColW": 130, + "importColW": 130 }, } @@ -105,8 +103,7 @@ def testCoreOptions_LoadSave(monkeypatch, mockGUI, fncPath): @pytest.mark.core def testCoreOptions_SetGet(mockGUI): - """Test setting and getting values from the OptionState class. - """ + """Test setting and getting values from the OptionState class.""" theProject = NWProject(mockGUI) theOpts = OptionState(theProject) @@ -114,36 +111,33 @@ def testCoreOptions_SetGet(mockGUI): # Set invalid values assert theOpts.setValue("MockGroup", "mockItem", None) is False - assert theOpts.setValue("GuiBuildNovel", "mockItem", None) is False + assert theOpts.setValue("GuiProjectSettings", "mockItem", None) is False # Set valid value - assert theOpts.setValue("GuiBuildNovel", "winWidth", 100) + assert theOpts.setValue("GuiProjectSettings", "winWidth", 100) is True # Set some values of different types - assert theOpts.setValue("GuiBuildNovel", "winWidth", 100) - assert theOpts.setValue("GuiBuildNovel", "winHeight", 12.34) - assert theOpts.setValue("GuiBuildNovel", "addNovel", True) - assert theOpts.setValue("GuiBuildNovel", "textFont", "Cantarell") - assert theOpts.setValue("GuiNovelView", "lastCol", nwColHidden) + assert theOpts.setValue("GuiProjectDetails", "winWidth", 100) is True + assert theOpts.setValue("GuiProjectDetails", "winHeight", 12.34) is True + assert theOpts.setValue("GuiProjectDetails", "clearDouble", True) is True + assert theOpts.setValue("GuiNovelView", "lastCol", nwColHidden) is True # Generic get, doesn't check type - assert theOpts.getValue("GuiBuildNovel", "winWidth", None) == 100 - assert theOpts.getValue("GuiBuildNovel", "winHeight", None) == 12.34 - assert theOpts.getValue("GuiBuildNovel", "addNovel", None) is True - assert theOpts.getValue("GuiBuildNovel", "textFont", None) == "Cantarell" - assert theOpts.getValue("GuiBuildNovel", "mockItem", None) is None + assert theOpts.getValue("GuiProjectDetails", "winWidth", None) == 100 + assert theOpts.getValue("GuiProjectDetails", "winHeight", None) == 12.34 + assert theOpts.getValue("GuiProjectDetails", "clearDouble", None) is True + assert theOpts.getValue("GuiProjectDetails", "mockItem", None) is None # Get type-specific - assert theOpts.getString("GuiBuildNovel", "winWidth", None) is None - assert theOpts.getString("GuiBuildNovel", "mockItem", None) is None - assert theOpts.getInt("GuiBuildNovel", "winWidth", None) == 100 - assert theOpts.getInt("GuiBuildNovel", "textFont", None) is None - assert theOpts.getInt("GuiBuildNovel", "mockItem", None) is None - assert theOpts.getFloat("GuiBuildNovel", "winWidth", None) == 100.0 - assert theOpts.getFloat("GuiBuildNovel", "textFont", None) is None - assert theOpts.getFloat("GuiBuildNovel", "mockItem", None) is None - assert theOpts.getBool("GuiBuildNovel", "addNovel", None) is True - assert theOpts.getBool("GuiBuildNovel", "mockItem", None) is None + assert theOpts.getString("GuiProjectDetails", "winWidth", None) is None + assert theOpts.getString("GuiProjectDetails", "mockItem", None) is None + assert theOpts.getInt("GuiProjectDetails", "winWidth", None) == 100 + assert theOpts.getInt("GuiProjectDetails", "textFont", None) is None + assert theOpts.getInt("GuiProjectDetails", "mockItem", None) is None + assert theOpts.getFloat("GuiProjectDetails", "winWidth", None) == 100.0 + assert theOpts.getFloat("GuiProjectDetails", "mockItem", None) is None + assert theOpts.getBool("GuiProjectDetails", "clearDouble", None) is True + assert theOpts.getBool("GuiProjectDetails", "mockItem", None) is None assert theOpts.getEnum("GuiNovelView", "lastCol", NovelTreeColumn, None) == nwColHidden # Get from non-existent groups diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py index f5b9ecc6..7c49585d 100644 --- a/tests/test_gui/test_gui_guimain.py +++ b/tests/test_gui/test_gui_guimain.py @@ -63,7 +63,7 @@ def testGuiMain_ProjectBlocker(nwGUI): assert nwGUI.rebuildIndex() is False assert nwGUI.showProjectSettingsDialog() is False assert nwGUI.showProjectDetailsDialog() is False - assert nwGUI.showBuildProjectDialog() is False + assert nwGUI.showBuildManuscriptDialog() is False assert nwGUI.showProjectWordListDialog() is False assert nwGUI.showWritingStatsDialog() is False diff --git a/tests/test_tools/test_tools_build.py b/tests/test_tools/test_tools_build.py deleted file mode 100644 index 798c3dfa..00000000 --- a/tests/test_tools/test_tools_build.py +++ /dev/null @@ -1,262 +0,0 @@ -""" -novelWriter – Build Dialog Class Tester -======================================= - -This file is a part of novelWriter -Copyright 2018–2023, 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 . -""" - -import pytest - -from shutil import copyfile - -from tools import ODT_IGNORE, cmpFiles, getGuiItem - -from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QAction, QFileDialog - -from novelwriter import CONFIG -from novelwriter.tools.build import GuiBuildNovel - - -@pytest.mark.gui -@pytest.mark.skip -def testToolBuild_Main(qtbot, monkeypatch, nwGUI, prjLipsum, tstPaths): - """Test the build tool. - """ - # Block message box - monkeypatch.setattr(QFileDialog, "getSaveFileName", lambda a, b, c, **k: (c, None)) - - # Check that we cannot open when there is no project - nwGUI.mainMenu.aBuildProject.activate(QAction.Trigger) - assert getGuiItem("GuiBuildNovel") is None - - # Open a project - assert nwGUI.openProject(prjLipsum) - - # Open the tool - nwGUI.mainMenu.aBuildProject.activate(QAction.Trigger) - qtbot.waitUntil(lambda: getGuiItem("GuiBuildNovel") is not None, timeout=1000) - - nwBuild = getGuiItem("GuiBuildNovel") - assert isinstance(nwBuild, GuiBuildNovel) - - nwBuild.textFont.setText("DejaVu Sans") - nwBuild.textSize.setValue(11) - - # Test Save - # ========= - - # Invalid file format - assert not nwBuild._saveDocument(-1) - - # No path selected - with monkeypatch.context() as mp: - mp.setattr(QFileDialog, "getSaveFileName", lambda *a, **k: ("", "")) - assert not nwBuild._saveDocument(nwBuild.FMT_NWD) - - # Default Settings - CONFIG._lastPath = prjLipsum - qtbot.mouseClick(nwBuild.buildNovel, Qt.LeftButton) - - assert nwBuild._saveDocument(nwBuild.FMT_NWD) - projFile = prjLipsum / "Lorem Ipsum.nwd" - testFile = tstPaths.outDir / "guiBuild_Tool_Step1_Lorem_Ipsum.nwd" - compFile = tstPaths.refDir / "guiBuild_Tool_Step1_Lorem_Ipsum.nwd" - copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile) - - assert nwBuild._saveDocument(nwBuild.FMT_HTM) - projFile = prjLipsum / "Lorem Ipsum.htm" - testFile = tstPaths.outDir / "guiBuild_Tool_Step1_Lorem_Ipsum.htm" - compFile = tstPaths.refDir / "guiBuild_Tool_Step1_Lorem_Ipsum.htm" - copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile) - - assert nwBuild._saveDocument(nwBuild.FMT_MD) - projFile = prjLipsum / "Lorem Ipsum.md" - testFile = tstPaths.outDir / "guiBuild_Tool_Step1_Lorem_Ipsum.md" - compFile = tstPaths.refDir / "guiBuild_Tool_Step1_Lorem_Ipsum.md" - copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile) - - assert nwBuild._saveDocument(nwBuild.FMT_GH) - projFile = prjLipsum / "Lorem Ipsum.md" - testFile = tstPaths.outDir / "guiBuild_Tool_Step1G_Lorem_Ipsum.md" - compFile = tstPaths.refDir / "guiBuild_Tool_Step1G_Lorem_Ipsum.md" - copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile) - - assert nwBuild._saveDocument(nwBuild.FMT_FODT) - projFile = prjLipsum / "Lorem Ipsum.fodt" - testFile = tstPaths.outDir / "guiBuild_Tool_Step1_Lorem_Ipsum.fodt" - compFile = tstPaths.refDir / "guiBuild_Tool_Step1_Lorem_Ipsum.fodt" - copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile, ignoreStart=ODT_IGNORE) - - # Change Title Formats and Flip Switches - nwBuild.fmtChapter.setText(r"Chapter %chw%: %title%") - nwBuild.fmtScene.setText(r"Scene %ch%.%sc%: %title%") - nwBuild.fmtSection.setText(r"%ch%.%sc%.1: %title%") - - qtbot.mouseClick(nwBuild.justifyText, Qt.LeftButton) - qtbot.mouseClick(nwBuild.includeSynopsis, Qt.LeftButton) - qtbot.mouseClick(nwBuild.includeComments, Qt.LeftButton) - qtbot.mouseClick(nwBuild.includeKeywords, Qt.LeftButton) - qtbot.mouseClick(nwBuild.replaceUCode, Qt.LeftButton) - - qtbot.mouseClick(nwBuild.noteFiles, Qt.LeftButton) - qtbot.mouseClick(nwBuild.ignoreFlag, Qt.LeftButton) - - qtbot.mouseClick(nwBuild.buildNovel, Qt.LeftButton) - - assert nwBuild._saveDocument(nwBuild.FMT_NWD) - projFile = prjLipsum / "Lorem Ipsum.nwd" - testFile = tstPaths.outDir / "guiBuild_Tool_Step2_Lorem_Ipsum.nwd" - compFile = tstPaths.refDir / "guiBuild_Tool_Step2_Lorem_Ipsum.nwd" - copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile) - - assert nwBuild._saveDocument(nwBuild.FMT_HTM) - projFile = prjLipsum / "Lorem Ipsum.htm" - testFile = tstPaths.outDir / "guiBuild_Tool_Step2_Lorem_Ipsum.htm" - compFile = tstPaths.refDir / "guiBuild_Tool_Step2_Lorem_Ipsum.htm" - copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile) - - assert nwBuild._saveDocument(nwBuild.FMT_MD) - projFile = prjLipsum / "Lorem Ipsum.md" - testFile = tstPaths.outDir / "guiBuild_Tool_Step2_Lorem_Ipsum.md" - compFile = tstPaths.refDir / "guiBuild_Tool_Step2_Lorem_Ipsum.md" - copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile) - - assert nwBuild._saveDocument(nwBuild.FMT_FODT) - projFile = prjLipsum / "Lorem Ipsum.fodt" - testFile = tstPaths.outDir / "guiBuild_Tool_Step2_Lorem_Ipsum.fodt" - compFile = tstPaths.refDir / "guiBuild_Tool_Step2_Lorem_Ipsum.fodt" - copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile, ignoreStart=ODT_IGNORE) - - # Replace Tabs with Spaces - qtbot.mouseClick(nwBuild.replaceTabs, Qt.LeftButton) - - qtbot.mouseClick(nwBuild.buildNovel, Qt.LeftButton) - - # Save files that can be compared - assert nwBuild._saveDocument(nwBuild.FMT_NWD) - projFile = prjLipsum / "Lorem Ipsum.nwd" - testFile = tstPaths.outDir / "guiBuild_Tool_Step3_Lorem_Ipsum.nwd" - compFile = tstPaths.refDir / "guiBuild_Tool_Step3_Lorem_Ipsum.nwd" - copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile) - - assert nwBuild._saveDocument(nwBuild.FMT_HTM) - projFile = prjLipsum / "Lorem Ipsum.htm" - testFile = tstPaths.outDir / "guiBuild_Tool_Step3_Lorem_Ipsum.htm" - compFile = tstPaths.refDir / "guiBuild_Tool_Step3_Lorem_Ipsum.htm" - copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile) - - assert nwBuild._saveDocument(nwBuild.FMT_MD) - projFile = prjLipsum / "Lorem Ipsum.md" - testFile = tstPaths.outDir / "guiBuild_Tool_Step3_Lorem_Ipsum.md" - compFile = tstPaths.refDir / "guiBuild_Tool_Step3_Lorem_Ipsum.md" - copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile) - - assert nwBuild._saveDocument(nwBuild.FMT_FODT) - projFile = prjLipsum / "Lorem Ipsum.fodt" - testFile = tstPaths.outDir / "guiBuild_Tool_Step3_Lorem_Ipsum.fodt" - compFile = tstPaths.refDir / "guiBuild_Tool_Step3_Lorem_Ipsum.fodt" - copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile, ignoreStart=ODT_IGNORE) - - # Putline Mode - nwBuild.fmtChapter.setText(r"Chapter %chw%: %title%") - nwBuild.fmtScene.setText(r"Scene %sca%: %title%") - nwBuild.fmtSection.setText(r"Section: %title%") - - qtbot.mouseClick(nwBuild.includeComments, Qt.LeftButton) - qtbot.mouseClick(nwBuild.noteFiles, Qt.LeftButton) - qtbot.mouseClick(nwBuild.ignoreFlag, Qt.LeftButton) - qtbot.mouseClick(nwBuild.includeBody, Qt.LeftButton) - - qtbot.mouseClick(nwBuild.buildNovel, Qt.LeftButton) - - # Save files that can be compared - assert nwBuild._saveDocument(nwBuild.FMT_NWD) - projFile = prjLipsum / "Lorem Ipsum.nwd" - testFile = tstPaths.outDir / "guiBuild_Tool_Step4_Lorem_Ipsum.nwd" - compFile = tstPaths.refDir / "guiBuild_Tool_Step4_Lorem_Ipsum.nwd" - copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile) - - assert nwBuild._saveDocument(nwBuild.FMT_HTM) - projFile = prjLipsum / "Lorem Ipsum.htm" - testFile = tstPaths.outDir / "guiBuild_Tool_Step4_Lorem_Ipsum.htm" - compFile = tstPaths.refDir / "guiBuild_Tool_Step4_Lorem_Ipsum.htm" - copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile) - - # Check the JSON files too at this stage - assert nwBuild._saveDocument(nwBuild.FMT_JSON_H) - projFile = prjLipsum / "Lorem Ipsum.json" - testFile = tstPaths.outDir / "guiBuild_Tool_Step4H_Lorem_Ipsum.json" - compFile = tstPaths.refDir / "guiBuild_Tool_Step4H_Lorem_Ipsum.json" - copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile, [6]) - - assert nwBuild._saveDocument(nwBuild.FMT_JSON_M) - projFile = prjLipsum / "Lorem Ipsum.json" - testFile = tstPaths.outDir / "guiBuild_Tool_Step4M_Lorem_Ipsum.json" - compFile = tstPaths.refDir / "guiBuild_Tool_Step4M_Lorem_Ipsum.json" - copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile, [6]) - - # Since odt and fodt is built by the same code, we don't check the - # output. but just that the different format can be written as well - assert nwBuild._saveDocument(nwBuild.FMT_ODT) - assert (prjLipsum / "Lorem Ipsum.odt").is_file() - - # Print to PDF - if not CONFIG.osDarwin: - assert nwBuild._saveDocument(nwBuild.FMT_PDF) - assert (prjLipsum / "Lorem Ipsum.pdf").is_file() - - # Close the build tool - htmlText = nwBuild.htmlText - htmlStyle = nwBuild.htmlStyle - buildTime = nwBuild.buildTime - nwBuild._doClose() - - # Re-open build dialog from cahce - nwGUI.mainMenu.aBuildProject.activate(QAction.Trigger) - qtbot.waitUntil(lambda: getGuiItem("GuiBuildNovel") is not None, timeout=1000) - - nwBuild = getGuiItem("GuiBuildNovel") - assert isinstance(nwBuild, GuiBuildNovel) - - assert nwBuild.viewCachedDoc() - assert nwBuild.htmlText == htmlText - assert nwBuild.htmlStyle == htmlStyle - assert nwBuild.buildTime == buildTime - - nwBuild._doClose() - - # qtbot.stop() - -# END Test testToolBuild_Main From d915b1b02c21bb5a0388b3fb95ee1ab3df66a221 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 3 Jun 2023 17:13:54 +0200 Subject: [PATCH 50/74] Remove old test reference files --- .../guiBuild_Tool_Step1G_Lorem_Ipsum.md | 88 -------- .../guiBuild_Tool_Step1_Lorem_Ipsum.fodt | 142 ------------ .../guiBuild_Tool_Step1_Lorem_Ipsum.htm | 71 ------ .../guiBuild_Tool_Step1_Lorem_Ipsum.md | 88 -------- .../guiBuild_Tool_Step1_Lorem_Ipsum.nwd | 109 --------- .../guiBuild_Tool_Step2_Lorem_Ipsum.fodt | 209 ------------------ .../guiBuild_Tool_Step2_Lorem_Ipsum.htm | 126 ----------- .../guiBuild_Tool_Step2_Lorem_Ipsum.md | 183 --------------- .../guiBuild_Tool_Step2_Lorem_Ipsum.nwd | 184 --------------- .../guiBuild_Tool_Step3_Lorem_Ipsum.fodt | 209 ------------------ .../guiBuild_Tool_Step3_Lorem_Ipsum.htm | 126 ----------- .../guiBuild_Tool_Step3_Lorem_Ipsum.md | 183 --------------- .../guiBuild_Tool_Step3_Lorem_Ipsum.nwd | 184 --------------- .../guiBuild_Tool_Step4H_Lorem_Ipsum.json | 91 -------- .../guiBuild_Tool_Step4M_Lorem_Ipsum.json | 149 ------------- .../guiBuild_Tool_Step4_Lorem_Ipsum.htm | 68 ------ .../guiBuild_Tool_Step4_Lorem_Ipsum.nwd | 104 --------- 17 files changed, 2314 deletions(-) delete mode 100644 tests/reference/guiBuild_Tool_Step1G_Lorem_Ipsum.md delete mode 100644 tests/reference/guiBuild_Tool_Step1_Lorem_Ipsum.fodt delete mode 100644 tests/reference/guiBuild_Tool_Step1_Lorem_Ipsum.htm delete mode 100644 tests/reference/guiBuild_Tool_Step1_Lorem_Ipsum.md delete mode 100644 tests/reference/guiBuild_Tool_Step1_Lorem_Ipsum.nwd delete mode 100644 tests/reference/guiBuild_Tool_Step2_Lorem_Ipsum.fodt delete mode 100644 tests/reference/guiBuild_Tool_Step2_Lorem_Ipsum.htm delete mode 100644 tests/reference/guiBuild_Tool_Step2_Lorem_Ipsum.md delete mode 100644 tests/reference/guiBuild_Tool_Step2_Lorem_Ipsum.nwd delete mode 100644 tests/reference/guiBuild_Tool_Step3_Lorem_Ipsum.fodt delete mode 100644 tests/reference/guiBuild_Tool_Step3_Lorem_Ipsum.htm delete mode 100644 tests/reference/guiBuild_Tool_Step3_Lorem_Ipsum.md delete mode 100644 tests/reference/guiBuild_Tool_Step3_Lorem_Ipsum.nwd delete mode 100644 tests/reference/guiBuild_Tool_Step4H_Lorem_Ipsum.json delete mode 100644 tests/reference/guiBuild_Tool_Step4M_Lorem_Ipsum.json delete mode 100644 tests/reference/guiBuild_Tool_Step4_Lorem_Ipsum.htm delete mode 100644 tests/reference/guiBuild_Tool_Step4_Lorem_Ipsum.nwd diff --git a/tests/reference/guiBuild_Tool_Step1G_Lorem_Ipsum.md b/tests/reference/guiBuild_Tool_Step1G_Lorem_Ipsum.md deleted file mode 100644 index 8a24d29f..00000000 --- a/tests/reference/guiBuild_Tool_Step1G_Lorem_Ipsum.md +++ /dev/null @@ -1,88 +0,0 @@ -# Lorem Ipsum - -**By lipsum.com** - -“Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit…” - -“There is no one who loves pain itself, who seeks after it and wants to have it, simply because it is pain…” - -Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of “de Finibus Bonorum et Malorum” (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, “Lorem ipsum dolor sit amet..”, comes from a line in section 1.10.32. - -The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from “de Finibus Bonorum et Malorum” by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham. - -## Prologue - -_Lorem Ipsum_ is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. - -# Act One - -“Fusce maximus felis libero” - -## Chapter 1: Chapter One - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam. Praesent magna nunc, lacinia sit amet quam eget, aliquet ultrices justo. Morbi ornare enim et lorem rutrum finibus ut eu dolor. Aliquam a orci odio. Ut ultrices sem quis massa placerat, eget mollis nisl cursus. Cras vel sagittis justo. Ut non ultricies leo. Maecenas rutrum velit in est varius, et egestas massa pulvinar. - -* * * - -Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur. Donec quis ante nunc. Mauris ut leo ipsum. Vestibulum est neque, hendrerit nec neque a, ullamcorper lobortis tellus. Fusce sollicitudin purus quis congue bibendum. Aliquam condimentum ipsum tristique blandit tristique. Donec pulvinar neque ac suscipit malesuada. - -Aliquam ut nisl arcu. Ut ultricies, lorem dignissim rutrum convallis, risus orci tempus lectus, congue feugiat sem lectus vitae odio. Duis sit amet justo finibus, hendrerit nulla at, ullamcorper enim. Praesent vel tellus sit amet tellus vulputate bibendum. Morbi eleifend sagittis sem, ac volutpat ante congue non. In hac habitasse platea dictumst. Morbi lobortis fermentum elit, dignissim sagittis ligula volutpat lacinia. Vestibulum eu interdum odio. Integer ac purus commodo metus congue tempor non at urna. Sed eget tortor vel quam viverra egestas. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Donec non convallis mauris, ac feugiat ex. - -Integer vel libero ipsum. Donec varius aliquam libero, sit amet commodo urna hendrerit non. Nullam quis erat mollis nunc viverra volutpat tincidunt in odio. Nam vitae quam sem. Aliquam suscipit nulla non lorem pharetra semper. Ut suscipit erat eu ligula accumsan ultrices. Phasellus nisl tellus, placerat sed laoreet id, consectetur nec dolor. Sed fringilla ipsum id dapibus posuere. Aenean finibus pharetra tincidunt. Ut molestie malesuada nulla, id posuere lorem tincidunt eu. Aliquam tempor eros a est vulputate, scelerisque pulvinar ipsum fermentum. In hac habitasse platea dictumst. - -Curabitur congue, justo quis interdum fermentum, tellus nulla imperdiet sapien, eu interdum enim tellus condimentum metus. Vivamus nunc velit, dignissim ut ultrices sit amet, ultricies quis enim. Donec ut vestibulum neque. Vivamus semper neque id ex ullamcorper varius. Fusce mattis nibh viverra lorem sagittis, et tempor arcu congue. Suspendisse sit amet felis sed urna facilisis mattis eget vitae arcu. Proin eu magna hendrerit, tristique sem maximus, placerat diam. Nulla tristique sed velit sit amet varius. Etiam vel ornare magna, in vulputate arcu. Cras velit orci, tincidunt sed volutpat cursus, bibendum vel sem. Nunc vulputate pharetra tortor, ac consectetur neque tincidunt sit amet. Nulla ornare mi sed mi dignissim ultricies. Ut tincidunt bibendum mauris, sed elementum ex vulputate vel. Mauris fermentum, felis nec vehicula congue, felis lorem facilisis erat, a dictum dolor augue vitae quam. Maecenas rutrum tortor nec consequat eleifend. - -* * * - -Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci. Vestibulum facilisis bibendum aliquam. Aliquam posuere, turpis ac bibendum varius, sem tellus venenatis risus, in elementum massa enim ac lorem. Integer in sem ac diam blandit ultricies ut in nulla. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam sit amet erat est. Curabitur vitae cursus justo, sit amet placerat dolor. Vivamus eu felis hendrerit, tincidunt massa rutrum, maximus arcu. Pellentesque commodo justo odio, vel rutrum nulla tincidunt eu. Integer non neque condimentum, convallis diam non, varius ligula. Aliquam eget sapien mauris. Aenean pharetra nunc nisi, vel maximus ante tristique sit amet. Aliquam risus metus, interdum non odio eu, consectetur lacinia sapien. - -Proin vitae gravida nisl. Integer viverra orci turpis, sit amet pretium ligula facilisis consequat. Nulla interdum commodo metus, mollis consequat dui tincidunt et. Proin consequat bibendum justo id commodo. Fusce fermentum nunc turpis, eu vestibulum risus feugiat ut. Sed scelerisque vel ligula ut interdum. Suspendisse ac blandit ligula, sagittis fringilla dolor. In tincidunt convallis diam et ornare. Aenean id dignissim est, ut rhoncus quam. Donec vitae nisl velit. In convallis nibh ut augue dignissim, eu elementum quam cursus. Phasellus in lectus lorem. Curabitur in pellentesque nisi, at gravida sapien. Sed cursus justo volutpat lacus placerat, sit amet dignissim turpis commodo. Aliquam vitae orci eget nulla posuere condimentum in ut felis. - -Nulla accumsan ante in pulvinar efficitur. Nulla non velit quis urna hendrerit bibendum. Suspendisse ultrices ante eu justo malesuada, sed fermentum enim rutrum. Nunc fermentum pharetra felis, vitae sollicitudin quam rutrum porta. Aliquam fringilla velit a mi laoreet, et luctus est rutrum. In gravida non ipsum sit amet tempus. Curabitur et eleifend purus. Nulla facilisi. - -Suspendisse potenti. Fusce tempus lorem nec laoreet suscipit. Fusce vulputate nisl ac diam tincidunt, nec malesuada quam pellentesque. Maecenas congue, tellus quis commodo rutrum, magna leo egestas arcu, quis suscipit ex risus id ligula. Suspendisse potenti. Morbi blandit lacus vitae laoreet vulputate. Donec vitae tellus eleifend, lobortis eros eu, tincidunt enim. Nullam et ullamcorper nisi. Vivamus tellus ex, lobortis quis rutrum ut, dapibus sit amet turpis. Phasellus pellentesque metus diam, commodo tristique ante commodo ac. Ut mollis ipsum nec diam blandit sollicitudin. Duis bibendum lacus nec commodo dapibus. Sed condimentum luctus ante, id ultricies urna varius nec. Nam convallis magna nec bibendum ultrices. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed auctor pharetra quam, vitae porta ex bibendum eu. - -Vivamus ut venenatis lectus. Phasellus nec elit id sem dictum ornare. Quisque feugiat, diam eget sagittis ultricies, orci turpis efficitur nisi, et fringilla justo odio nec nibh. In hac habitasse platea dictumst. Sed tempus bibendum feugiat. Etiam luctus mauris arcu, non interdum ipsum ultrices id. Vivamus blandit urna sit amet scelerisque vulputate. Quisque in metus eget massa rutrum dictum sit amet sed nulla. Vivamus vel efficitur dolor. - -Ut et consequat enim, quis ornare nibh. In lectus neque, mollis et suscipit et, vestibulum vitae augue. Praesent id ante sit amet odio venenatis placerat a at erat. Sed sed metus sed nisi dictum varius. Integer tincidunt fermentum purus ac porta. Fusce porttitor non risus eget tristique. Donec augue nunc, maximus at fermentum vel, varius et neque. Ut sed consectetur mauris. Quisque ipsum enim, porttitor vitae imperdiet sit amet, tempor et mauris. Aliquam malesuada tincidunt lectus quis blandit. Sed commodo orci felis, quis ultrices tellus facilisis sed. Nunc vel varius est. Duis ullamcorper eu metus in pulvinar. Morbi at sapien dictum, rutrum mauris eget, interdum tellus. - -## Chapter 2: Chapter Two - -Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue. Vestibulum pharetra fermentum leo, sed faucibus eros placerat quis. In hac habitasse platea dictumst. Donec metus massa, rutrum quis consequat et, tincidunt ac felis. Duis mollis metus ac nunc tincidunt blandit. Ut aliquet velit eu odio pharetra condimentum. Integer rutrum lacus orci, id venenatis libero accumsan at. - -* * * - -Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Aenean tincidunt lacus vitae nibh elementum eleifend. Sed rutrum condimentum sem quis blandit. Duis imperdiet libero metus, quis convallis quam faucibus a. Nulla ligula est, semper quis sollicitudin et, pretium id justo. Curabitur pharetra risus eget consectetur commodo. Duis mattis arcu non est condimentum, id venenatis risus volutpat. Pellentesque aliquet mauris non mauris porttitor ultrices. Phasellus ut vestibulum mi. Suspendisse malesuada metus lorem, a malesuada orci rhoncus a. Praesent euismod convallis ante, lacinia tincidunt ex egestas id. Praesent sit amet efficitur sapien. Morbi tincidunt volutpat nunc sed dictum. Aliquam ultrices metus id fermentum lobortis. - -Pellentesque id sagittis dui. Praesent ut nisi sit amet libero euismod ornare. Vestibulum vehicula, lorem eget aliquet imperdiet, eros nulla iaculis mi, vel bibendum est dui sed orci. Nullam vitae lorem rutrum, euismod lacus id, ullamcorper lectus. Duis nec commodo mi, a fringilla diam. Vestibulum molestie nibh tristique, viverra augue non, aliquet metus. Phasellus a tellus ac nisl tempor aliquet. Nulla vitae sapien rutrum augue ornare ultrices a quis nisi. Sed pulvinar tincidunt ex. Fusce vel sem vitae ante pellentesque lobortis. - -Maecenas ullamcorper lacus nec turpis finibus aliquet eget rutrum augue. Integer lorem erat, faucibus non lacus lacinia, pulvinar egestas felis. Proin rutrum nunc eget nulla varius, id blandit mauris tincidunt. Donec sit amet ullamcorper nisi, ut efficitur mi. Aliquam aliquet, nulla eget rhoncus tristique, justo lorem consectetur dui, id ornare leo odio sed tellus. Curabitur interdum velit a turpis condimentum venenatis. Nunc rhoncus sem ac augue auctor, nec malesuada ex fringilla. Vestibulum egestas diam sed leo consectetur vulputate quis eget enim. Nam tincidunt metus sit amet maximus ullamcorper. Sed placerat velit vitae massa efficitur viverra. Etiam eleifend dignissim ante, sed luctus nisl tristique a. In vestibulum pharetra dolor in molestie. Vivamus auctor massa ac magna imperdiet, sit amet iaculis turpis finibus. - -Aenean dapibus vulputate purus, sit amet tempor nunc suscipit consequat. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Mauris auctor congue eros, non pellentesque neque dapibus ac. Vestibulum non leo nec urna lacinia eleifend quis et diam. Praesent eu nisi magna. Nulla at magna massa. Suspendisse porta varius scelerisque. Duis at auctor dolor, non dapibus urna. Nunc venenatis feugiat magna non molestie. Aliquam non ornare ex. Quisque eu ultrices velit, quis pellentesque eros. Phasellus eleifend, elit id imperdiet aliquam, nulla quam molestie turpis, at egestas odio ante et tortor. Suspendisse fringilla condimentum justo, at aliquet odio aliquam ac. - -* * * - -Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo. Nullam viverra dui et auctor pretium. Ut ullamcorper velit urna, sed imperdiet massa convallis a. Suspendisse efficitur, ipsum nec cursus pulvinar, eros urna posuere diam, nec elementum mi felis vitae sapien. - -Duis efficitur metus pulvinar, molestie magna eget, feugiat dui. Fusce convallis vehicula ipsum convallis blandit. Duis eros risus, malesuada eu imperdiet in, hendrerit ac metus. Vestibulum id justo gravida, dignissim nibh non, iaculis diam. Fusce accumsan est ut massa porta ultricies. Nulla vitae justo in tortor laoreet mollis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin eu libero justo. Vivamus aliquet placerat est, et auctor eros posuere venenatis. Nunc quam diam, tincidunt ac aliquet in, fermentum sit amet lectus. Proin commodo tincidunt blandit. Quisque erat arcu, semper nec dui non, consectetur gravida ipsum. Nullam pretium consectetur elit at condimentum. - -Etiam sagittis, erat vitae accumsan tempor, neque augue scelerisque nulla, ut ultrices justo urna sit amet augue. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean at pulvinar tortor. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Cras vel porta quam. Nullam eu mauris mollis, vehicula justo vel, placerat sapien. Phasellus viverra elit et vestibulum pharetra. Vestibulum commodo fermentum leo, eu porta nisi aliquam eget. Nulla tempus porttitor nisi nec mollis. Nam non mollis turpis. Nam finibus leo a bibendum tincidunt. Donec commodo velit magna, ac semper sapien mattis id. Proin sem velit, lobortis quis ultricies id, pharetra et lectus. Vestibulum condimentum neque vitae mi dapibus mollis. Mauris luctus vel sapien vitae hendrerit. - -Aenean vestibulum magna placerat fermentum tempus. Nam auctor condimentum nunc, in elementum quam ornare a. Etiam in ipsum elit. Proin pharetra, dolor sollicitudin pellentesque congue, lorem dolor ultricies magna, non iaculis risus nisl dictum diam. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Vivamus vel euismod nibh, et lobortis dolor. Maecenas dui odio, gravida nec molestie ut, feugiat ut arcu. Pellentesque risus sapien, gravida a convallis quis, ullamcorper porttitor sapien. - -Donec ipsum eros, vestibulum sit amet cursus eget, iaculis quis dolor. Pellentesque magna augue, tristique dapibus mi vitae, molestie venenatis enim. Nam malesuada, turpis volutpat rhoncus ullamcorper, justo est eleifend orci, ut luctus risus ex rutrum arcu. Sed mi elit, feugiat rhoncus ornare sed, porta id leo. Pellentesque feugiat nulla tincidunt erat suscipit, eu congue lacus hendrerit. Morbi pulvinar enim sed consequat auctor. Ut eleifend enim sem, vitae euismod ex ultricies sit amet. Curabitur eu efficitur nisi, suscipit finibus sapien. In sodales blandit erat, vestibulum pulvinar ante volutpat nec. Vivamus dictum non libero at molestie. Donec sit amet neque in ante convallis pretium. Nunc vel iaculis dui. - -Phasellus eu nunc ut nunc faucibus laoreet. Aliquam at magna risus. Praesent lobortis, risus finibus semper varius, magna purus vestibulum eros, at pulvinar sapien enim a ex. In scelerisque malesuada ex, sit amet egestas neque condimentum sed. Praesent vulputate efficitur massa. Cras at accumsan ligula. In elementum lectus eget blandit dictum. Nam vitae libero ut justo eleifend rutrum ac nec arcu. Aliquam sodales in quam congue vestibulum. Aliquam in accumsan sapien. Quisque lobortis nisl nisi, vitae bibendum turpis efficitur sed. Vestibulum tempor nulla eget nisi convallis, blandit sagittis ipsum convallis. Donec odio nibh, ultrices quis odio in, mollis euismod libero. - -* * * - -Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. In sed felis auctor, rhoncus dui ac, consequat dolor. Integer volutpat libero sed nisl aliquet varius. Suspendisse et lorem sapien. Proin id ultrices nibh, ac suscipit diam. Suspendisse placerat varius porttitor. Curabitur elementum sed enim ultrices imperdiet. - -In ut lobortis lacus, nec luctus arcu. Vivamus condimentum sapien a ipsum malesuada sodales. Donec et vestibulum risus. Integer dictum euismod eros id tincidunt. Aliquam sagittis leo vitae consequat fermentum. Donec maximus ex eu ex iaculis porta. Praesent pharetra lacinia risus, et eleifend diam commodo non. Sed feugiat ipsum ut orci sagittis, quis faucibus lectus blandit. Sed tellus quam, gravida vitae laoreet quis, tempus lobortis dui. Vivamus semper accumsan ullamcorper. Praesent tempus pretium eros, non elementum risus. Pellentesque odio quam, auctor quis ex non, vulputate egestas dolor. Nunc luctus enim ut justo sodales consectetur. Sed aliquet a mauris vel posuere. - -Donec luctus lectus efficitur, blandit nisi vitae, dignissim tellus. Pellentesque euismod pharetra augue gravida hendrerit. Quisque nisi mi, mattis ac nisi non, maximus malesuada ante. Nulla lobortis, diam eu ornare ornare, tellus enim feugiat arcu, non vestibulum tortor nunc eu justo. Integer blandit felis justo, eu semper est scelerisque vel. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nam ultricies, nisi vel elementum commodo, nisl dolor tincidunt magna, sed varius est nunc at lectus. Aliquam dolor tortor, sodales placerat ultricies quis, sodales quis sapien. Duis ullamcorper sollicitudin risus at mattis. Integer consequat et nunc at condimentum. Pellentesque cursus congue augue, non suscipit lectus sodales ut. Nam a mi bibendum, blandit nisl eu, accumsan nunc. Aliquam a ex mauris. Sed nec sem quis arcu dignissim tempus eget et turpis. Ut sed ex nec ipsum ultrices lobortis. - -Pellentesque rhoncus pharetra eros, non mollis nisi pretium non. Mauris accumsan quis odio quis euismod. Maecenas ultrices, augue et aliquam tincidunt, erat tellus ornare ligula, quis ultrices turpis nibh vel justo. Fusce gravida odio tellus. In a congue diam. Mauris consequat ex id leo lacinia dictum. Fusce id sem sodales, ultrices sapien ac, convallis orci. Donec gravida nunc sit amet nisi hendrerit, sed porta enim aliquam. In hac habitasse platea dictumst. Cras a orci felis. Curabitur non felis nec urna maximus auctor ut ut nisi. Curabitur at turpis eleifend, blandit eros at, molestie odio. Phasellus euismod neque augue. - -Integer egestas maximus leo eu facilisis. Nunc rhoncus dignissim lectus eu lacinia. Praesent lacinia urna porttitor aliquam condimentum. Nulla eu eros dictum, dictum nunc vitae, sagittis nibh. Integer ante neque, consequat nec sollicitudin id, consectetur vitae dolor. Nullam volutpat sem orci, quis viverra magna auctor a. Suspendisse potenti. Maecenas commodo sed neque pellentesque vehicula. Sed luctus nisl risus, elementum semper purus interdum vel. Ut pulvinar, massa sit amet venenatis placerat, nunc lacus hendrerit odio, non aliquet nunc risus eu lectus. Maecenas feugiat semper ligula, id lobortis sem porta eu. Integer posuere elit magna, at mollis eros bibendum et. Ut imperdiet purus vel nulla aliquam maximus. Morbi sodales purus tellus, a rhoncus sem rutrum sit amet. Quisque risus sem, laoreet nec convallis nec, rutrum vitae justo. - diff --git a/tests/reference/guiBuild_Tool_Step1_Lorem_Ipsum.fodt b/tests/reference/guiBuild_Tool_Step1_Lorem_Ipsum.fodt deleted file mode 100644 index 4bd71182..00000000 --- a/tests/reference/guiBuild_Tool_Step1_Lorem_Ipsum.fodt +++ /dev/null @@ -1,142 +0,0 @@ - - - - 2023-05-29T22:10:29 - novelWriter/2.0.7 - lipsum.com - 40 - P0DT0H31M45S - Lorem Ipsum - 2023-05-29T22:10:29 - lipsum.com - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Lorem Ipsum / lipsum.com / 2 - - - - - - - - - Lorem Ipsum - By lipsum.com - “Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit…” - “There is no one who loves pain itself, who seeks after it and wants to have it, simply because it is pain…” - Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of “de Finibus Bonorum et Malorum” (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, “Lorem ipsum dolor sit amet..”, comes from a line in section 1.10.32. - The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from “de Finibus Bonorum et Malorum” by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham. - Prologue - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. - Act One - “Fusce maximus felis libero” - Chapter 1: Chapter One - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam. Praesent magna nunc, lacinia sit amet quam eget, aliquet ultrices justo. Morbi ornare enim et lorem rutrum finibus ut eu dolor. Aliquam a orci odio. Ut ultrices sem quis massa placerat, eget mollis nisl cursus. Cras vel sagittis justo. Ut non ultricies leo. Maecenas rutrum velit in est varius, et egestas massa pulvinar. - * * * - Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur. Donec quis ante nunc. Mauris ut leo ipsum. Vestibulum est neque, hendrerit nec neque a, ullamcorper lobortis tellus. Fusce sollicitudin purus quis congue bibendum. Aliquam condimentum ipsum tristique blandit tristique. Donec pulvinar neque ac suscipit malesuada. - Aliquam ut nisl arcu. Ut ultricies, lorem dignissim rutrum convallis, risus orci tempus lectus, congue feugiat sem lectus vitae odio. Duis sit amet justo finibus, hendrerit nulla at, ullamcorper enim. Praesent vel tellus sit amet tellus vulputate bibendum. Morbi eleifend sagittis sem, ac volutpat ante congue non. In hac habitasse platea dictumst. Morbi lobortis fermentum elit, dignissim sagittis ligula volutpat lacinia. Vestibulum eu interdum odio. Integer ac purus commodo metus congue tempor non at urna. Sed eget tortor vel quam viverra egestas. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Donec non convallis mauris, ac feugiat ex. - Integer vel libero ipsum. Donec varius aliquam libero, sit amet commodo urna hendrerit non. Nullam quis erat mollis nunc viverra volutpat tincidunt in odio. Nam vitae quam sem. Aliquam suscipit nulla non lorem pharetra semper. Ut suscipit erat eu ligula accumsan ultrices. Phasellus nisl tellus, placerat sed laoreet id, consectetur nec dolor. Sed fringilla ipsum id dapibus posuere. Aenean finibus pharetra tincidunt. Ut molestie malesuada nulla, id posuere lorem tincidunt eu. Aliquam tempor eros a est vulputate, scelerisque pulvinar ipsum fermentum. In hac habitasse platea dictumst. - Curabitur congue, justo quis interdum fermentum, tellus nulla imperdiet sapien, eu interdum enim tellus condimentum metus. Vivamus nunc velit, dignissim ut ultrices sit amet, ultricies quis enim. Donec ut vestibulum neque. Vivamus semper neque id ex ullamcorper varius. Fusce mattis nibh viverra lorem sagittis, et tempor arcu congue. Suspendisse sit amet felis sed urna facilisis mattis eget vitae arcu. Proin eu magna hendrerit, tristique sem maximus, placerat diam. Nulla tristique sed velit sit amet varius. Etiam vel ornare magna, in vulputate arcu. Cras velit orci, tincidunt sed volutpat cursus, bibendum vel sem. Nunc vulputate pharetra tortor, ac consectetur neque tincidunt sit amet. Nulla ornare mi sed mi dignissim ultricies. Ut tincidunt bibendum mauris, sed elementum ex vulputate vel. Mauris fermentum, felis nec vehicula congue, felis lorem facilisis erat, a dictum dolor augue vitae quam. Maecenas rutrum tortor nec consequat eleifend. - * * * - Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci. Vestibulum facilisis bibendum aliquam. Aliquam posuere, turpis ac bibendum varius, sem tellus venenatis risus, in elementum massa enim ac lorem. Integer in sem ac diam blandit ultricies ut in nulla. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam sit amet erat est. Curabitur vitae cursus justo, sit amet placerat dolor. Vivamus eu felis hendrerit, tincidunt massa rutrum, maximus arcu. Pellentesque commodo justo odio, vel rutrum nulla tincidunt eu. Integer non neque condimentum, convallis diam non, varius ligula. Aliquam eget sapien mauris. Aenean pharetra nunc nisi, vel maximus ante tristique sit amet. Aliquam risus metus, interdum non odio eu, consectetur lacinia sapien. - Proin vitae gravida nisl. Integer viverra orci turpis, sit amet pretium ligula facilisis consequat. Nulla interdum commodo metus, mollis consequat dui tincidunt et. Proin consequat bibendum justo id commodo. Fusce fermentum nunc turpis, eu vestibulum risus feugiat ut. Sed scelerisque vel ligula ut interdum. Suspendisse ac blandit ligula, sagittis fringilla dolor. In tincidunt convallis diam et ornare. Aenean id dignissim est, ut rhoncus quam. Donec vitae nisl velit. In convallis nibh ut augue dignissim, eu elementum quam cursus. Phasellus in lectus lorem. Curabitur in pellentesque nisi, at gravida sapien. Sed cursus justo volutpat lacus placerat, sit amet dignissim turpis commodo. Aliquam vitae orci eget nulla posuere condimentum in ut felis. - Nulla accumsan ante in pulvinar efficitur. Nulla non velit quis urna hendrerit bibendum. Suspendisse ultrices ante eu justo malesuada, sed fermentum enim rutrum. Nunc fermentum pharetra felis, vitae sollicitudin quam rutrum porta. Aliquam fringilla velit a mi laoreet, et luctus est rutrum. In gravida non ipsum sit amet tempus. Curabitur et eleifend purus. Nulla facilisi. - Suspendisse potenti. Fusce tempus lorem nec laoreet suscipit. Fusce vulputate nisl ac diam tincidunt, nec malesuada quam pellentesque. Maecenas congue, tellus quis commodo rutrum, magna leo egestas arcu, quis suscipit ex risus id ligula. Suspendisse potenti. Morbi blandit lacus vitae laoreet vulputate. Donec vitae tellus eleifend, lobortis eros eu, tincidunt enim. Nullam et ullamcorper nisi. Vivamus tellus ex, lobortis quis rutrum ut, dapibus sit amet turpis. Phasellus pellentesque metus diam, commodo tristique ante commodo ac. Ut mollis ipsum nec diam blandit sollicitudin. Duis bibendum lacus nec commodo dapibus. Sed condimentum luctus ante, id ultricies urna varius nec. Nam convallis magna nec bibendum ultrices. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed auctor pharetra quam, vitae porta ex bibendum eu. - Vivamus ut venenatis lectus. Phasellus nec elit id sem dictum ornare. Quisque feugiat, diam eget sagittis ultricies, orci turpis efficitur nisi, et fringilla justo odio nec nibh. In hac habitasse platea dictumst. Sed tempus bibendum feugiat. Etiam luctus mauris arcu, non interdum ipsum ultrices id. Vivamus blandit urna sit amet scelerisque vulputate. Quisque in metus eget massa rutrum dictum sit amet sed nulla. Vivamus vel efficitur dolor. - Ut et consequat enim, quis ornare nibh. In lectus neque, mollis et suscipit et, vestibulum vitae augue. Praesent id ante sit amet odio venenatis placerat a at erat. Sed sed metus sed nisi dictum varius. Integer tincidunt fermentum purus ac porta. Fusce porttitor non risus eget tristique. Donec augue nunc, maximus at fermentum vel, varius et neque. Ut sed consectetur mauris. Quisque ipsum enim, porttitor vitae imperdiet sit amet, tempor et mauris. Aliquam malesuada tincidunt lectus quis blandit. Sed commodo orci felis, quis ultrices tellus facilisis sed. Nunc vel varius est. Duis ullamcorper eu metus in pulvinar. Morbi at sapien dictum, rutrum mauris eget, interdum tellus. - Chapter 2: Chapter Two - Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue. Vestibulum pharetra fermentum leo, sed faucibus eros placerat quis. In hac habitasse platea dictumst. Donec metus massa, rutrum quis consequat et, tincidunt ac felis. Duis mollis metus ac nunc tincidunt blandit. Ut aliquet velit eu odio pharetra condimentum. Integer rutrum lacus orci, id venenatis libero accumsan at. - * * * - Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Aenean tincidunt lacus vitae nibh elementum eleifend. Sed rutrum condimentum sem quis blandit. Duis imperdiet libero metus, quis convallis quam faucibus a. Nulla ligula est, semper quis sollicitudin et, pretium id justo. Curabitur pharetra risus eget consectetur commodo. Duis mattis arcu non est condimentum, id venenatis risus volutpat. Pellentesque aliquet mauris non mauris porttitor ultrices. Phasellus ut vestibulum mi. Suspendisse malesuada metus lorem, a malesuada orci rhoncus a. Praesent euismod convallis ante, lacinia tincidunt ex egestas id. Praesent sit amet efficitur sapien. Morbi tincidunt volutpat nunc sed dictum. Aliquam ultrices metus id fermentum lobortis. - Pellentesque id sagittis dui. Praesent ut nisi sit amet libero euismod ornare. Vestibulum vehicula, lorem eget aliquet imperdiet, eros nulla iaculis mi, vel bibendum est dui sed orci. Nullam vitae lorem rutrum, euismod lacus id, ullamcorper lectus. Duis nec commodo mi, a fringilla diam. Vestibulum molestie nibh tristique, viverra augue non, aliquet metus. Phasellus a tellus ac nisl tempor aliquet. Nulla vitae sapien rutrum augue ornare ultrices a quis nisi. Sed pulvinar tincidunt ex. Fusce vel sem vitae ante pellentesque lobortis. - Maecenas ullamcorper lacus nec turpis finibus aliquet eget rutrum augue. Integer lorem erat, faucibus non lacus lacinia, pulvinar egestas felis. Proin rutrum nunc eget nulla varius, id blandit mauris tincidunt. Donec sit amet ullamcorper nisi, ut efficitur mi. Aliquam aliquet, nulla eget rhoncus tristique, justo lorem consectetur dui, id ornare leo odio sed tellus. Curabitur interdum velit a turpis condimentum venenatis. Nunc rhoncus sem ac augue auctor, nec malesuada ex fringilla. Vestibulum egestas diam sed leo consectetur vulputate quis eget enim. Nam tincidunt metus sit amet maximus ullamcorper. Sed placerat velit vitae massa efficitur viverra. Etiam eleifend dignissim ante, sed luctus nisl tristique a. In vestibulum pharetra dolor in molestie. Vivamus auctor massa ac magna imperdiet, sit amet iaculis turpis finibus. - Aenean dapibus vulputate purus, sit amet tempor nunc suscipit consequat. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Mauris auctor congue eros, non pellentesque neque dapibus ac. Vestibulum non leo nec urna lacinia eleifend quis et diam. Praesent eu nisi magna. Nulla at magna massa. Suspendisse porta varius scelerisque. Duis at auctor dolor, non dapibus urna. Nunc venenatis feugiat magna non molestie. Aliquam non ornare ex. Quisque eu ultrices velit, quis pellentesque eros. Phasellus eleifend, elit id imperdiet aliquam, nulla quam molestie turpis, at egestas odio ante et tortor. Suspendisse fringilla condimentum justo, at aliquet odio aliquam ac. - * * * - Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo. Nullam viverra dui et auctor pretium. Ut ullamcorper velit urna, sed imperdiet massa convallis a. Suspendisse efficitur, ipsum nec cursus pulvinar, eros urna posuere diam, nec elementum mi felis vitae sapien. - Duis efficitur metus pulvinar, molestie magna eget, feugiat dui. Fusce convallis vehicula ipsum convallis blandit. Duis eros risus, malesuada eu imperdiet in, hendrerit ac metus. Vestibulum id justo gravida, dignissim nibh non, iaculis diam. Fusce accumsan est ut massa porta ultricies. Nulla vitae justo in tortor laoreet mollis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin eu libero justo. Vivamus aliquet placerat est, et auctor eros posuere venenatis. Nunc quam diam, tincidunt ac aliquet in, fermentum sit amet lectus. Proin commodo tincidunt blandit. Quisque erat arcu, semper nec dui non, consectetur gravida ipsum. Nullam pretium consectetur elit at condimentum. - Etiam sagittis, erat vitae accumsan tempor, neque augue scelerisque nulla, ut ultrices justo urna sit amet augue. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean at pulvinar tortor. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Cras vel porta quam. Nullam eu mauris mollis, vehicula justo vel, placerat sapien. Phasellus viverra elit et vestibulum pharetra. Vestibulum commodo fermentum leo, eu porta nisi aliquam eget. Nulla tempus porttitor nisi nec mollis. Nam non mollis turpis. Nam finibus leo a bibendum tincidunt. Donec commodo velit magna, ac semper sapien mattis id. Proin sem velit, lobortis quis ultricies id, pharetra et lectus. Vestibulum condimentum neque vitae mi dapibus mollis. Mauris luctus vel sapien vitae hendrerit. - Aenean vestibulum magna placerat fermentum tempus. Nam auctor condimentum nunc, in elementum quam ornare a. Etiam in ipsum elit. Proin pharetra, dolor sollicitudin pellentesque congue, lorem dolor ultricies magna, non iaculis risus nisl dictum diam. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Vivamus vel euismod nibh, et lobortis dolor. Maecenas dui odio, gravida nec molestie ut, feugiat ut arcu. Pellentesque risus sapien, gravida a convallis quis, ullamcorper porttitor sapien. - Donec ipsum eros, vestibulum sit amet cursus eget, iaculis quis dolor. Pellentesque magna augue, tristique dapibus mi vitae, molestie venenatis enim. Nam malesuada, turpis volutpat rhoncus ullamcorper, justo est eleifend orci, ut luctus risus ex rutrum arcu. Sed mi elit, feugiat rhoncus ornare sed, porta id leo. Pellentesque feugiat nulla tincidunt erat suscipit, eu congue lacus hendrerit. Morbi pulvinar enim sed consequat auctor. Ut eleifend enim sem, vitae euismod ex ultricies sit amet. Curabitur eu efficitur nisi, suscipit finibus sapien. In sodales blandit erat, vestibulum pulvinar ante volutpat nec. Vivamus dictum non libero at molestie. Donec sit amet neque in ante convallis pretium. Nunc vel iaculis dui. - Phasellus eu nunc ut nunc faucibus laoreet. Aliquam at magna risus. Praesent lobortis, risus finibus semper varius, magna purus vestibulum eros, at pulvinar sapien enim a ex. In scelerisque malesuada ex, sit amet egestas neque condimentum sed. Praesent vulputate efficitur massa. Cras at accumsan ligula. In elementum lectus eget blandit dictum. Nam vitae libero ut justo eleifend rutrum ac nec arcu. Aliquam sodales in quam congue vestibulum. Aliquam in accumsan sapien. Quisque lobortis nisl nisi, vitae bibendum turpis efficitur sed. Vestibulum tempor nulla eget nisi convallis, blandit sagittis ipsum convallis. Donec odio nibh, ultrices quis odio in, mollis euismod libero. - * * * - Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. In sed felis auctor, rhoncus dui ac, consequat dolor. Integer volutpat libero sed nisl aliquet varius. Suspendisse et lorem sapien. Proin id ultrices nibh, ac suscipit diam. Suspendisse placerat varius porttitor. Curabitur elementum sed enim ultrices imperdiet. - In ut lobortis lacus, nec luctus arcu. Vivamus condimentum sapien a ipsum malesuada sodales. Donec et vestibulum risus. Integer dictum euismod eros id tincidunt. Aliquam sagittis leo vitae consequat fermentum. Donec maximus ex eu ex iaculis porta. Praesent pharetra lacinia risus, et eleifend diam commodo non. Sed feugiat ipsum ut orci sagittis, quis faucibus lectus blandit. Sed tellus quam, gravida vitae laoreet quis, tempus lobortis dui. Vivamus semper accumsan ullamcorper. Praesent tempus pretium eros, non elementum risus. Pellentesque odio quam, auctor quis ex non, vulputate egestas dolor. Nunc luctus enim ut justo sodales consectetur. Sed aliquet a mauris vel posuere. - Donec luctus lectus efficitur, blandit nisi vitae, dignissim tellus. Pellentesque euismod pharetra augue gravida hendrerit. Quisque nisi mi, mattis ac nisi non, maximus malesuada ante. Nulla lobortis, diam eu ornare ornare, tellus enim feugiat arcu, non vestibulum tortor nunc eu justo. Integer blandit felis justo, eu semper est scelerisque vel. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nam ultricies, nisi vel elementum commodo, nisl dolor tincidunt magna, sed varius est nunc at lectus. Aliquam dolor tortor, sodales placerat ultricies quis, sodales quis sapien. Duis ullamcorper sollicitudin risus at mattis. Integer consequat et nunc at condimentum. Pellentesque cursus congue augue, non suscipit lectus sodales ut. Nam a mi bibendum, blandit nisl eu, accumsan nunc. Aliquam a ex mauris. Sed nec sem quis arcu dignissim tempus eget et turpis. Ut sed ex nec ipsum ultrices lobortis. - Pellentesque rhoncus pharetra eros, non mollis nisi pretium non. Mauris accumsan quis odio quis euismod. Maecenas ultrices, augue et aliquam tincidunt, erat tellus ornare ligula, quis ultrices turpis nibh vel justo. Fusce gravida odio tellus. In a congue diam. Mauris consequat ex id leo lacinia dictum. Fusce id sem sodales, ultrices sapien ac, convallis orci. Donec gravida nunc sit amet nisi hendrerit, sed porta enim aliquam. In hac habitasse platea dictumst. Cras a orci felis. Curabitur non felis nec urna maximus auctor ut ut nisi. Curabitur at turpis eleifend, blandit eros at, molestie odio. Phasellus euismod neque augue. - Integer egestas maximus leo eu facilisis. Nunc rhoncus dignissim lectus eu lacinia. Praesent lacinia urna porttitor aliquam condimentum. Nulla eu eros dictum, dictum nunc vitae, sagittis nibh. Integer ante neque, consequat nec sollicitudin id, consectetur vitae dolor. Nullam volutpat sem orci, quis viverra magna auctor a. Suspendisse potenti. Maecenas commodo sed neque pellentesque vehicula. Sed luctus nisl risus, elementum semper purus interdum vel. Ut pulvinar, massa sit amet venenatis placerat, nunc lacus hendrerit odio, non aliquet nunc risus eu lectus. Maecenas feugiat semper ligula, id lobortis sem porta eu. Integer posuere elit magna, at mollis eros bibendum et. Ut imperdiet purus vel nulla aliquam maximus. Morbi sodales purus tellus, a rhoncus sem rutrum sit amet. Quisque risus sem, laoreet nec convallis nec, rutrum vitae justo. - - - diff --git a/tests/reference/guiBuild_Tool_Step1_Lorem_Ipsum.htm b/tests/reference/guiBuild_Tool_Step1_Lorem_Ipsum.htm deleted file mode 100644 index 2295991a..00000000 --- a/tests/reference/guiBuild_Tool_Step1_Lorem_Ipsum.htm +++ /dev/null @@ -1,71 +0,0 @@ - - - - -Lorem Ipsum - - - -
-

Lorem Ipsum

-

By lipsum.com

-

“Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit…”

-

“There is no one who loves pain itself, who seeks after it and wants to have it, simply because it is pain…”

-

Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of “de Finibus Bonorum et Malorum” (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, “Lorem ipsum dolor sit amet..”, comes from a line in section 1.10.32.

-

The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from “de Finibus Bonorum et Malorum” by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham.

-

Prologue

-

Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.

-

Act One

-

“Fusce maximus felis libero”

-

Chapter 1: Chapter One

-

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam. Praesent magna nunc, lacinia sit amet quam eget, aliquet ultrices justo. Morbi ornare enim et lorem rutrum finibus ut eu dolor. Aliquam a orci odio. Ut ultrices sem quis massa placerat, eget mollis nisl cursus. Cras vel sagittis justo. Ut non ultricies leo. Maecenas rutrum velit in est varius, et egestas massa pulvinar.

-

* * *

-

Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur. Donec quis ante nunc. Mauris ut leo ipsum. Vestibulum est neque, hendrerit nec neque a, ullamcorper lobortis tellus. Fusce sollicitudin purus quis congue bibendum. Aliquam condimentum ipsum tristique blandit tristique. Donec pulvinar neque ac suscipit malesuada.

-

Aliquam ut nisl arcu. Ut ultricies, lorem dignissim rutrum convallis, risus orci tempus lectus, congue feugiat sem lectus vitae odio. Duis sit amet justo finibus, hendrerit nulla at, ullamcorper enim. Praesent vel tellus sit amet tellus vulputate bibendum. Morbi eleifend sagittis sem, ac volutpat ante congue non. In hac habitasse platea dictumst. Morbi lobortis fermentum elit, dignissim sagittis ligula volutpat lacinia. Vestibulum eu interdum odio. Integer ac purus commodo metus congue tempor non at urna. Sed eget tortor vel quam viverra egestas. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Donec non convallis mauris, ac feugiat ex.

-

Integer vel libero ipsum. Donec varius aliquam libero, sit amet commodo urna hendrerit non. Nullam quis erat mollis nunc viverra volutpat tincidunt in odio. Nam vitae quam sem. Aliquam suscipit nulla non lorem pharetra semper. Ut suscipit erat eu ligula accumsan ultrices. Phasellus nisl tellus, placerat sed laoreet id, consectetur nec dolor. Sed fringilla ipsum id dapibus posuere. Aenean finibus pharetra tincidunt. Ut molestie malesuada nulla, id posuere lorem tincidunt eu. Aliquam tempor eros a est vulputate, scelerisque pulvinar ipsum fermentum. In hac habitasse platea dictumst.

-

Curabitur congue, justo quis interdum fermentum, tellus nulla imperdiet sapien, eu interdum enim tellus condimentum metus. Vivamus nunc velit, dignissim ut ultrices sit amet, ultricies quis enim. Donec ut vestibulum neque. Vivamus semper neque id ex ullamcorper varius. Fusce mattis nibh viverra lorem sagittis, et tempor arcu congue. Suspendisse sit amet felis sed urna facilisis mattis eget vitae arcu. Proin eu magna hendrerit, tristique sem maximus, placerat diam. Nulla tristique sed velit sit amet varius. Etiam vel ornare magna, in vulputate arcu. Cras velit orci, tincidunt sed volutpat cursus, bibendum vel sem. Nunc vulputate pharetra tortor, ac consectetur neque tincidunt sit amet. Nulla ornare mi sed mi dignissim ultricies. Ut tincidunt bibendum mauris, sed elementum ex vulputate vel. Mauris fermentum, felis nec vehicula congue, felis lorem facilisis erat, a dictum dolor augue vitae quam. Maecenas rutrum tortor nec consequat eleifend.

-

* * *

-

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci. Vestibulum facilisis bibendum aliquam. Aliquam posuere, turpis ac bibendum varius, sem tellus venenatis risus, in elementum massa enim ac lorem. Integer in sem ac diam blandit ultricies ut in nulla. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam sit amet erat est. Curabitur vitae cursus justo, sit amet placerat dolor. Vivamus eu felis hendrerit, tincidunt massa rutrum, maximus arcu. Pellentesque commodo justo odio, vel rutrum nulla tincidunt eu. Integer non neque condimentum, convallis diam non, varius ligula. Aliquam eget sapien mauris. Aenean pharetra nunc nisi, vel maximus ante tristique sit amet. Aliquam risus metus, interdum non odio eu, consectetur lacinia sapien.

-

Proin vitae gravida nisl. Integer viverra orci turpis, sit amet pretium ligula facilisis consequat. Nulla interdum commodo metus, mollis consequat dui tincidunt et. Proin consequat bibendum justo id commodo. Fusce fermentum nunc turpis, eu vestibulum risus feugiat ut. Sed scelerisque vel ligula ut interdum. Suspendisse ac blandit ligula, sagittis fringilla dolor. In tincidunt convallis diam et ornare. Aenean id dignissim est, ut rhoncus quam. Donec vitae nisl velit. In convallis nibh ut augue dignissim, eu elementum quam cursus. Phasellus in lectus lorem. Curabitur in pellentesque nisi, at gravida sapien. Sed cursus justo volutpat lacus placerat, sit amet dignissim turpis commodo. Aliquam vitae orci eget nulla posuere condimentum in ut felis.

-

Nulla accumsan ante in pulvinar efficitur. Nulla non velit quis urna hendrerit bibendum. Suspendisse ultrices ante eu justo malesuada, sed fermentum enim rutrum. Nunc fermentum pharetra felis, vitae sollicitudin quam rutrum porta. Aliquam fringilla velit a mi laoreet, et luctus est rutrum. In gravida non ipsum sit amet tempus. Curabitur et eleifend purus. Nulla facilisi.

-

Suspendisse potenti. Fusce tempus lorem nec laoreet suscipit. Fusce vulputate nisl ac diam tincidunt, nec malesuada quam pellentesque. Maecenas congue, tellus quis commodo rutrum, magna leo egestas arcu, quis suscipit ex risus id ligula. Suspendisse potenti. Morbi blandit lacus vitae laoreet vulputate. Donec vitae tellus eleifend, lobortis eros eu, tincidunt enim. Nullam et ullamcorper nisi. Vivamus tellus ex, lobortis quis rutrum ut, dapibus sit amet turpis. Phasellus pellentesque metus diam, commodo tristique ante commodo ac. Ut mollis ipsum nec diam blandit sollicitudin. Duis bibendum lacus nec commodo dapibus. Sed condimentum luctus ante, id ultricies urna varius nec. Nam convallis magna nec bibendum ultrices. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed auctor pharetra quam, vitae porta ex bibendum eu.

-

Vivamus ut venenatis lectus. Phasellus nec elit id sem dictum ornare. Quisque feugiat, diam eget sagittis ultricies, orci turpis efficitur nisi, et fringilla justo odio nec nibh. In hac habitasse platea dictumst. Sed tempus bibendum feugiat. Etiam luctus mauris arcu, non interdum ipsum ultrices id. Vivamus blandit urna sit amet scelerisque vulputate. Quisque in metus eget massa rutrum dictum sit amet sed nulla. Vivamus vel efficitur dolor.

-

Ut et consequat enim, quis ornare nibh. In lectus neque, mollis et suscipit et, vestibulum vitae augue. Praesent id ante sit amet odio venenatis placerat a at erat. Sed sed metus sed nisi dictum varius. Integer tincidunt fermentum purus ac porta. Fusce porttitor non risus eget tristique. Donec augue nunc, maximus at fermentum vel, varius et neque. Ut sed consectetur mauris. Quisque ipsum enim, porttitor vitae imperdiet sit amet, tempor et mauris. Aliquam malesuada tincidunt lectus quis blandit. Sed commodo orci felis, quis ultrices tellus facilisis sed. Nunc vel varius est. Duis ullamcorper eu metus in pulvinar. Morbi at sapien dictum, rutrum mauris eget, interdum tellus.

-

Chapter 2: Chapter Two

-

Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue. Vestibulum pharetra fermentum leo, sed faucibus eros placerat quis. In hac habitasse platea dictumst. Donec metus massa, rutrum quis consequat et, tincidunt ac felis. Duis mollis metus ac nunc tincidunt blandit. Ut aliquet velit eu odio pharetra condimentum. Integer rutrum lacus orci, id venenatis libero accumsan at.

-

* * *

-

Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Aenean tincidunt lacus vitae nibh elementum eleifend. Sed rutrum condimentum sem quis blandit. Duis imperdiet libero metus, quis convallis quam faucibus a. Nulla ligula est, semper quis sollicitudin et, pretium id justo. Curabitur pharetra risus eget consectetur commodo. Duis mattis arcu non est condimentum, id venenatis risus volutpat. Pellentesque aliquet mauris non mauris porttitor ultrices. Phasellus ut vestibulum mi. Suspendisse malesuada metus lorem, a malesuada orci rhoncus a. Praesent euismod convallis ante, lacinia tincidunt ex egestas id. Praesent sit amet efficitur sapien. Morbi tincidunt volutpat nunc sed dictum. Aliquam ultrices metus id fermentum lobortis.

-

Pellentesque id sagittis dui. Praesent ut nisi sit amet libero euismod ornare. Vestibulum vehicula, lorem eget aliquet imperdiet, eros nulla iaculis mi, vel bibendum est dui sed orci. Nullam vitae lorem rutrum, euismod lacus id, ullamcorper lectus. Duis nec commodo mi, a fringilla diam. Vestibulum molestie nibh tristique, viverra augue non, aliquet metus. Phasellus a tellus ac nisl tempor aliquet. Nulla vitae sapien rutrum augue ornare ultrices a quis nisi. Sed pulvinar tincidunt ex. Fusce vel sem vitae ante pellentesque lobortis.

-

Maecenas ullamcorper lacus nec turpis finibus aliquet eget rutrum augue. Integer lorem erat, faucibus non lacus lacinia, pulvinar egestas felis. Proin rutrum nunc eget nulla varius, id blandit mauris tincidunt. Donec sit amet ullamcorper nisi, ut efficitur mi. Aliquam aliquet, nulla eget rhoncus tristique, justo lorem consectetur dui, id ornare leo odio sed tellus. Curabitur interdum velit a turpis condimentum venenatis. Nunc rhoncus sem ac augue auctor, nec malesuada ex fringilla. Vestibulum egestas diam sed leo consectetur vulputate quis eget enim. Nam tincidunt metus sit amet maximus ullamcorper. Sed placerat velit vitae massa efficitur viverra. Etiam eleifend dignissim ante, sed luctus nisl tristique a. In vestibulum pharetra dolor in molestie. Vivamus auctor massa ac magna imperdiet, sit amet iaculis turpis finibus.

-

Aenean dapibus vulputate purus, sit amet tempor nunc suscipit consequat. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Mauris auctor congue eros, non pellentesque neque dapibus ac. Vestibulum non leo nec urna lacinia eleifend quis et diam. Praesent eu nisi magna. Nulla at magna massa. Suspendisse porta varius scelerisque. Duis at auctor dolor, non dapibus urna. Nunc venenatis feugiat magna non molestie. Aliquam non ornare ex. Quisque eu ultrices velit, quis pellentesque eros. Phasellus eleifend, elit id imperdiet aliquam, nulla quam molestie turpis, at egestas odio ante et tortor. Suspendisse fringilla condimentum justo, at aliquet odio aliquam ac.

-

* * *

-

Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo. Nullam viverra dui et auctor pretium. Ut ullamcorper velit urna, sed imperdiet massa convallis a. Suspendisse efficitur, ipsum nec cursus pulvinar, eros urna posuere diam, nec elementum mi felis vitae sapien.

-

Duis efficitur metus pulvinar, molestie magna eget, feugiat dui. Fusce convallis vehicula ipsum convallis blandit. Duis eros risus, malesuada eu imperdiet in, hendrerit ac metus. Vestibulum id justo gravida, dignissim nibh non, iaculis diam. Fusce accumsan est ut massa porta ultricies. Nulla vitae justo in tortor laoreet mollis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin eu libero justo. Vivamus aliquet placerat est, et auctor eros posuere venenatis. Nunc quam diam, tincidunt ac aliquet in, fermentum sit amet lectus. Proin commodo tincidunt blandit. Quisque erat arcu, semper nec dui non, consectetur gravida ipsum. Nullam pretium consectetur elit at condimentum.

-

Etiam sagittis, erat vitae accumsan tempor, neque augue scelerisque nulla, ut ultrices justo urna sit amet augue. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean at pulvinar tortor. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Cras vel porta quam. Nullam eu mauris mollis, vehicula justo vel, placerat sapien. Phasellus viverra elit et vestibulum pharetra. Vestibulum commodo fermentum leo, eu porta nisi aliquam eget. Nulla tempus porttitor nisi nec mollis. Nam non mollis turpis. Nam finibus leo a bibendum tincidunt. Donec commodo velit magna, ac semper sapien mattis id. Proin sem velit, lobortis quis ultricies id, pharetra et lectus. Vestibulum condimentum neque vitae mi dapibus mollis. Mauris luctus vel sapien vitae hendrerit.

-

Aenean vestibulum magna placerat fermentum tempus. Nam auctor condimentum nunc, in elementum quam ornare a. Etiam in ipsum elit. Proin pharetra, dolor sollicitudin pellentesque congue, lorem dolor ultricies magna, non iaculis risus nisl dictum diam. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Vivamus vel euismod nibh, et lobortis dolor. Maecenas dui odio, gravida nec molestie ut, feugiat ut arcu. Pellentesque risus sapien, gravida a convallis quis, ullamcorper porttitor sapien.

-

Donec ipsum eros, vestibulum sit amet cursus eget, iaculis quis dolor. Pellentesque magna augue, tristique dapibus mi vitae, molestie venenatis enim. Nam malesuada, turpis volutpat rhoncus ullamcorper, justo est eleifend orci, ut luctus risus ex rutrum arcu. Sed mi elit, feugiat rhoncus ornare sed, porta id leo. Pellentesque feugiat nulla tincidunt erat suscipit, eu congue lacus hendrerit. Morbi pulvinar enim sed consequat auctor. Ut eleifend enim sem, vitae euismod ex ultricies sit amet. Curabitur eu efficitur nisi, suscipit finibus sapien. In sodales blandit erat, vestibulum pulvinar ante volutpat nec. Vivamus dictum non libero at molestie. Donec sit amet neque in ante convallis pretium. Nunc vel iaculis dui.

-

Phasellus eu nunc ut nunc faucibus laoreet. Aliquam at magna risus. Praesent lobortis, risus finibus semper varius, magna purus vestibulum eros, at pulvinar sapien enim a ex. In scelerisque malesuada ex, sit amet egestas neque condimentum sed. Praesent vulputate efficitur massa. Cras at accumsan ligula. In elementum lectus eget blandit dictum. Nam vitae libero ut justo eleifend rutrum ac nec arcu. Aliquam sodales in quam congue vestibulum. Aliquam in accumsan sapien. Quisque lobortis nisl nisi, vitae bibendum turpis efficitur sed. Vestibulum tempor nulla eget nisi convallis, blandit sagittis ipsum convallis. Donec odio nibh, ultrices quis odio in, mollis euismod libero.

-

* * *

-

Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. In sed felis auctor, rhoncus dui ac, consequat dolor. Integer volutpat libero sed nisl aliquet varius. Suspendisse et lorem sapien. Proin id ultrices nibh, ac suscipit diam. Suspendisse placerat varius porttitor. Curabitur elementum sed enim ultrices imperdiet.

-

In ut lobortis lacus, nec luctus arcu. Vivamus condimentum sapien a ipsum malesuada sodales. Donec et vestibulum risus. Integer dictum euismod eros id tincidunt. Aliquam sagittis leo vitae consequat fermentum. Donec maximus ex eu ex iaculis porta. Praesent pharetra lacinia risus, et eleifend diam commodo non. Sed feugiat ipsum ut orci sagittis, quis faucibus lectus blandit. Sed tellus quam, gravida vitae laoreet quis, tempus lobortis dui. Vivamus semper accumsan ullamcorper. Praesent tempus pretium eros, non elementum risus. Pellentesque odio quam, auctor quis ex non, vulputate egestas dolor. Nunc luctus enim ut justo sodales consectetur. Sed aliquet a mauris vel posuere.

-

Donec luctus lectus efficitur, blandit nisi vitae, dignissim tellus. Pellentesque euismod pharetra augue gravida hendrerit. Quisque nisi mi, mattis ac nisi non, maximus malesuada ante. Nulla lobortis, diam eu ornare ornare, tellus enim feugiat arcu, non vestibulum tortor nunc eu justo. Integer blandit felis justo, eu semper est scelerisque vel. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nam ultricies, nisi vel elementum commodo, nisl dolor tincidunt magna, sed varius est nunc at lectus. Aliquam dolor tortor, sodales placerat ultricies quis, sodales quis sapien. Duis ullamcorper sollicitudin risus at mattis. Integer consequat et nunc at condimentum. Pellentesque cursus congue augue, non suscipit lectus sodales ut. Nam a mi bibendum, blandit nisl eu, accumsan nunc. Aliquam a ex mauris. Sed nec sem quis arcu dignissim tempus eget et turpis. Ut sed ex nec ipsum ultrices lobortis.

-

Pellentesque rhoncus pharetra eros, non mollis nisi pretium non. Mauris accumsan quis odio quis euismod. Maecenas ultrices, augue et aliquam tincidunt, erat tellus ornare ligula, quis ultrices turpis nibh vel justo. Fusce gravida odio tellus. In a congue diam. Mauris consequat ex id leo lacinia dictum. Fusce id sem sodales, ultrices sapien ac, convallis orci. Donec gravida nunc sit amet nisi hendrerit, sed porta enim aliquam. In hac habitasse platea dictumst. Cras a orci felis. Curabitur non felis nec urna maximus auctor ut ut nisi. Curabitur at turpis eleifend, blandit eros at, molestie odio. Phasellus euismod neque augue.

-

Integer egestas maximus leo eu facilisis. Nunc rhoncus dignissim lectus eu lacinia. Praesent lacinia urna porttitor aliquam condimentum. Nulla eu eros dictum, dictum nunc vitae, sagittis nibh. Integer ante neque, consequat nec sollicitudin id, consectetur vitae dolor. Nullam volutpat sem orci, quis viverra magna auctor a. Suspendisse potenti. Maecenas commodo sed neque pellentesque vehicula. Sed luctus nisl risus, elementum semper purus interdum vel. Ut pulvinar, massa sit amet venenatis placerat, nunc lacus hendrerit odio, non aliquet nunc risus eu lectus. Maecenas feugiat semper ligula, id lobortis sem porta eu. Integer posuere elit magna, at mollis eros bibendum et. Ut imperdiet purus vel nulla aliquam maximus. Morbi sodales purus tellus, a rhoncus sem rutrum sit amet. Quisque risus sem, laoreet nec convallis nec, rutrum vitae justo.

-
- - diff --git a/tests/reference/guiBuild_Tool_Step1_Lorem_Ipsum.md b/tests/reference/guiBuild_Tool_Step1_Lorem_Ipsum.md deleted file mode 100644 index 8a24d29f..00000000 --- a/tests/reference/guiBuild_Tool_Step1_Lorem_Ipsum.md +++ /dev/null @@ -1,88 +0,0 @@ -# Lorem Ipsum - -**By lipsum.com** - -“Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit…” - -“There is no one who loves pain itself, who seeks after it and wants to have it, simply because it is pain…” - -Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of “de Finibus Bonorum et Malorum” (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, “Lorem ipsum dolor sit amet..”, comes from a line in section 1.10.32. - -The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from “de Finibus Bonorum et Malorum” by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham. - -## Prologue - -_Lorem Ipsum_ is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. - -# Act One - -“Fusce maximus felis libero” - -## Chapter 1: Chapter One - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam. Praesent magna nunc, lacinia sit amet quam eget, aliquet ultrices justo. Morbi ornare enim et lorem rutrum finibus ut eu dolor. Aliquam a orci odio. Ut ultrices sem quis massa placerat, eget mollis nisl cursus. Cras vel sagittis justo. Ut non ultricies leo. Maecenas rutrum velit in est varius, et egestas massa pulvinar. - -* * * - -Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur. Donec quis ante nunc. Mauris ut leo ipsum. Vestibulum est neque, hendrerit nec neque a, ullamcorper lobortis tellus. Fusce sollicitudin purus quis congue bibendum. Aliquam condimentum ipsum tristique blandit tristique. Donec pulvinar neque ac suscipit malesuada. - -Aliquam ut nisl arcu. Ut ultricies, lorem dignissim rutrum convallis, risus orci tempus lectus, congue feugiat sem lectus vitae odio. Duis sit amet justo finibus, hendrerit nulla at, ullamcorper enim. Praesent vel tellus sit amet tellus vulputate bibendum. Morbi eleifend sagittis sem, ac volutpat ante congue non. In hac habitasse platea dictumst. Morbi lobortis fermentum elit, dignissim sagittis ligula volutpat lacinia. Vestibulum eu interdum odio. Integer ac purus commodo metus congue tempor non at urna. Sed eget tortor vel quam viverra egestas. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Donec non convallis mauris, ac feugiat ex. - -Integer vel libero ipsum. Donec varius aliquam libero, sit amet commodo urna hendrerit non. Nullam quis erat mollis nunc viverra volutpat tincidunt in odio. Nam vitae quam sem. Aliquam suscipit nulla non lorem pharetra semper. Ut suscipit erat eu ligula accumsan ultrices. Phasellus nisl tellus, placerat sed laoreet id, consectetur nec dolor. Sed fringilla ipsum id dapibus posuere. Aenean finibus pharetra tincidunt. Ut molestie malesuada nulla, id posuere lorem tincidunt eu. Aliquam tempor eros a est vulputate, scelerisque pulvinar ipsum fermentum. In hac habitasse platea dictumst. - -Curabitur congue, justo quis interdum fermentum, tellus nulla imperdiet sapien, eu interdum enim tellus condimentum metus. Vivamus nunc velit, dignissim ut ultrices sit amet, ultricies quis enim. Donec ut vestibulum neque. Vivamus semper neque id ex ullamcorper varius. Fusce mattis nibh viverra lorem sagittis, et tempor arcu congue. Suspendisse sit amet felis sed urna facilisis mattis eget vitae arcu. Proin eu magna hendrerit, tristique sem maximus, placerat diam. Nulla tristique sed velit sit amet varius. Etiam vel ornare magna, in vulputate arcu. Cras velit orci, tincidunt sed volutpat cursus, bibendum vel sem. Nunc vulputate pharetra tortor, ac consectetur neque tincidunt sit amet. Nulla ornare mi sed mi dignissim ultricies. Ut tincidunt bibendum mauris, sed elementum ex vulputate vel. Mauris fermentum, felis nec vehicula congue, felis lorem facilisis erat, a dictum dolor augue vitae quam. Maecenas rutrum tortor nec consequat eleifend. - -* * * - -Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci. Vestibulum facilisis bibendum aliquam. Aliquam posuere, turpis ac bibendum varius, sem tellus venenatis risus, in elementum massa enim ac lorem. Integer in sem ac diam blandit ultricies ut in nulla. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam sit amet erat est. Curabitur vitae cursus justo, sit amet placerat dolor. Vivamus eu felis hendrerit, tincidunt massa rutrum, maximus arcu. Pellentesque commodo justo odio, vel rutrum nulla tincidunt eu. Integer non neque condimentum, convallis diam non, varius ligula. Aliquam eget sapien mauris. Aenean pharetra nunc nisi, vel maximus ante tristique sit amet. Aliquam risus metus, interdum non odio eu, consectetur lacinia sapien. - -Proin vitae gravida nisl. Integer viverra orci turpis, sit amet pretium ligula facilisis consequat. Nulla interdum commodo metus, mollis consequat dui tincidunt et. Proin consequat bibendum justo id commodo. Fusce fermentum nunc turpis, eu vestibulum risus feugiat ut. Sed scelerisque vel ligula ut interdum. Suspendisse ac blandit ligula, sagittis fringilla dolor. In tincidunt convallis diam et ornare. Aenean id dignissim est, ut rhoncus quam. Donec vitae nisl velit. In convallis nibh ut augue dignissim, eu elementum quam cursus. Phasellus in lectus lorem. Curabitur in pellentesque nisi, at gravida sapien. Sed cursus justo volutpat lacus placerat, sit amet dignissim turpis commodo. Aliquam vitae orci eget nulla posuere condimentum in ut felis. - -Nulla accumsan ante in pulvinar efficitur. Nulla non velit quis urna hendrerit bibendum. Suspendisse ultrices ante eu justo malesuada, sed fermentum enim rutrum. Nunc fermentum pharetra felis, vitae sollicitudin quam rutrum porta. Aliquam fringilla velit a mi laoreet, et luctus est rutrum. In gravida non ipsum sit amet tempus. Curabitur et eleifend purus. Nulla facilisi. - -Suspendisse potenti. Fusce tempus lorem nec laoreet suscipit. Fusce vulputate nisl ac diam tincidunt, nec malesuada quam pellentesque. Maecenas congue, tellus quis commodo rutrum, magna leo egestas arcu, quis suscipit ex risus id ligula. Suspendisse potenti. Morbi blandit lacus vitae laoreet vulputate. Donec vitae tellus eleifend, lobortis eros eu, tincidunt enim. Nullam et ullamcorper nisi. Vivamus tellus ex, lobortis quis rutrum ut, dapibus sit amet turpis. Phasellus pellentesque metus diam, commodo tristique ante commodo ac. Ut mollis ipsum nec diam blandit sollicitudin. Duis bibendum lacus nec commodo dapibus. Sed condimentum luctus ante, id ultricies urna varius nec. Nam convallis magna nec bibendum ultrices. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed auctor pharetra quam, vitae porta ex bibendum eu. - -Vivamus ut venenatis lectus. Phasellus nec elit id sem dictum ornare. Quisque feugiat, diam eget sagittis ultricies, orci turpis efficitur nisi, et fringilla justo odio nec nibh. In hac habitasse platea dictumst. Sed tempus bibendum feugiat. Etiam luctus mauris arcu, non interdum ipsum ultrices id. Vivamus blandit urna sit amet scelerisque vulputate. Quisque in metus eget massa rutrum dictum sit amet sed nulla. Vivamus vel efficitur dolor. - -Ut et consequat enim, quis ornare nibh. In lectus neque, mollis et suscipit et, vestibulum vitae augue. Praesent id ante sit amet odio venenatis placerat a at erat. Sed sed metus sed nisi dictum varius. Integer tincidunt fermentum purus ac porta. Fusce porttitor non risus eget tristique. Donec augue nunc, maximus at fermentum vel, varius et neque. Ut sed consectetur mauris. Quisque ipsum enim, porttitor vitae imperdiet sit amet, tempor et mauris. Aliquam malesuada tincidunt lectus quis blandit. Sed commodo orci felis, quis ultrices tellus facilisis sed. Nunc vel varius est. Duis ullamcorper eu metus in pulvinar. Morbi at sapien dictum, rutrum mauris eget, interdum tellus. - -## Chapter 2: Chapter Two - -Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue. Vestibulum pharetra fermentum leo, sed faucibus eros placerat quis. In hac habitasse platea dictumst. Donec metus massa, rutrum quis consequat et, tincidunt ac felis. Duis mollis metus ac nunc tincidunt blandit. Ut aliquet velit eu odio pharetra condimentum. Integer rutrum lacus orci, id venenatis libero accumsan at. - -* * * - -Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Aenean tincidunt lacus vitae nibh elementum eleifend. Sed rutrum condimentum sem quis blandit. Duis imperdiet libero metus, quis convallis quam faucibus a. Nulla ligula est, semper quis sollicitudin et, pretium id justo. Curabitur pharetra risus eget consectetur commodo. Duis mattis arcu non est condimentum, id venenatis risus volutpat. Pellentesque aliquet mauris non mauris porttitor ultrices. Phasellus ut vestibulum mi. Suspendisse malesuada metus lorem, a malesuada orci rhoncus a. Praesent euismod convallis ante, lacinia tincidunt ex egestas id. Praesent sit amet efficitur sapien. Morbi tincidunt volutpat nunc sed dictum. Aliquam ultrices metus id fermentum lobortis. - -Pellentesque id sagittis dui. Praesent ut nisi sit amet libero euismod ornare. Vestibulum vehicula, lorem eget aliquet imperdiet, eros nulla iaculis mi, vel bibendum est dui sed orci. Nullam vitae lorem rutrum, euismod lacus id, ullamcorper lectus. Duis nec commodo mi, a fringilla diam. Vestibulum molestie nibh tristique, viverra augue non, aliquet metus. Phasellus a tellus ac nisl tempor aliquet. Nulla vitae sapien rutrum augue ornare ultrices a quis nisi. Sed pulvinar tincidunt ex. Fusce vel sem vitae ante pellentesque lobortis. - -Maecenas ullamcorper lacus nec turpis finibus aliquet eget rutrum augue. Integer lorem erat, faucibus non lacus lacinia, pulvinar egestas felis. Proin rutrum nunc eget nulla varius, id blandit mauris tincidunt. Donec sit amet ullamcorper nisi, ut efficitur mi. Aliquam aliquet, nulla eget rhoncus tristique, justo lorem consectetur dui, id ornare leo odio sed tellus. Curabitur interdum velit a turpis condimentum venenatis. Nunc rhoncus sem ac augue auctor, nec malesuada ex fringilla. Vestibulum egestas diam sed leo consectetur vulputate quis eget enim. Nam tincidunt metus sit amet maximus ullamcorper. Sed placerat velit vitae massa efficitur viverra. Etiam eleifend dignissim ante, sed luctus nisl tristique a. In vestibulum pharetra dolor in molestie. Vivamus auctor massa ac magna imperdiet, sit amet iaculis turpis finibus. - -Aenean dapibus vulputate purus, sit amet tempor nunc suscipit consequat. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Mauris auctor congue eros, non pellentesque neque dapibus ac. Vestibulum non leo nec urna lacinia eleifend quis et diam. Praesent eu nisi magna. Nulla at magna massa. Suspendisse porta varius scelerisque. Duis at auctor dolor, non dapibus urna. Nunc venenatis feugiat magna non molestie. Aliquam non ornare ex. Quisque eu ultrices velit, quis pellentesque eros. Phasellus eleifend, elit id imperdiet aliquam, nulla quam molestie turpis, at egestas odio ante et tortor. Suspendisse fringilla condimentum justo, at aliquet odio aliquam ac. - -* * * - -Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo. Nullam viverra dui et auctor pretium. Ut ullamcorper velit urna, sed imperdiet massa convallis a. Suspendisse efficitur, ipsum nec cursus pulvinar, eros urna posuere diam, nec elementum mi felis vitae sapien. - -Duis efficitur metus pulvinar, molestie magna eget, feugiat dui. Fusce convallis vehicula ipsum convallis blandit. Duis eros risus, malesuada eu imperdiet in, hendrerit ac metus. Vestibulum id justo gravida, dignissim nibh non, iaculis diam. Fusce accumsan est ut massa porta ultricies. Nulla vitae justo in tortor laoreet mollis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin eu libero justo. Vivamus aliquet placerat est, et auctor eros posuere venenatis. Nunc quam diam, tincidunt ac aliquet in, fermentum sit amet lectus. Proin commodo tincidunt blandit. Quisque erat arcu, semper nec dui non, consectetur gravida ipsum. Nullam pretium consectetur elit at condimentum. - -Etiam sagittis, erat vitae accumsan tempor, neque augue scelerisque nulla, ut ultrices justo urna sit amet augue. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean at pulvinar tortor. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Cras vel porta quam. Nullam eu mauris mollis, vehicula justo vel, placerat sapien. Phasellus viverra elit et vestibulum pharetra. Vestibulum commodo fermentum leo, eu porta nisi aliquam eget. Nulla tempus porttitor nisi nec mollis. Nam non mollis turpis. Nam finibus leo a bibendum tincidunt. Donec commodo velit magna, ac semper sapien mattis id. Proin sem velit, lobortis quis ultricies id, pharetra et lectus. Vestibulum condimentum neque vitae mi dapibus mollis. Mauris luctus vel sapien vitae hendrerit. - -Aenean vestibulum magna placerat fermentum tempus. Nam auctor condimentum nunc, in elementum quam ornare a. Etiam in ipsum elit. Proin pharetra, dolor sollicitudin pellentesque congue, lorem dolor ultricies magna, non iaculis risus nisl dictum diam. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Vivamus vel euismod nibh, et lobortis dolor. Maecenas dui odio, gravida nec molestie ut, feugiat ut arcu. Pellentesque risus sapien, gravida a convallis quis, ullamcorper porttitor sapien. - -Donec ipsum eros, vestibulum sit amet cursus eget, iaculis quis dolor. Pellentesque magna augue, tristique dapibus mi vitae, molestie venenatis enim. Nam malesuada, turpis volutpat rhoncus ullamcorper, justo est eleifend orci, ut luctus risus ex rutrum arcu. Sed mi elit, feugiat rhoncus ornare sed, porta id leo. Pellentesque feugiat nulla tincidunt erat suscipit, eu congue lacus hendrerit. Morbi pulvinar enim sed consequat auctor. Ut eleifend enim sem, vitae euismod ex ultricies sit amet. Curabitur eu efficitur nisi, suscipit finibus sapien. In sodales blandit erat, vestibulum pulvinar ante volutpat nec. Vivamus dictum non libero at molestie. Donec sit amet neque in ante convallis pretium. Nunc vel iaculis dui. - -Phasellus eu nunc ut nunc faucibus laoreet. Aliquam at magna risus. Praesent lobortis, risus finibus semper varius, magna purus vestibulum eros, at pulvinar sapien enim a ex. In scelerisque malesuada ex, sit amet egestas neque condimentum sed. Praesent vulputate efficitur massa. Cras at accumsan ligula. In elementum lectus eget blandit dictum. Nam vitae libero ut justo eleifend rutrum ac nec arcu. Aliquam sodales in quam congue vestibulum. Aliquam in accumsan sapien. Quisque lobortis nisl nisi, vitae bibendum turpis efficitur sed. Vestibulum tempor nulla eget nisi convallis, blandit sagittis ipsum convallis. Donec odio nibh, ultrices quis odio in, mollis euismod libero. - -* * * - -Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. In sed felis auctor, rhoncus dui ac, consequat dolor. Integer volutpat libero sed nisl aliquet varius. Suspendisse et lorem sapien. Proin id ultrices nibh, ac suscipit diam. Suspendisse placerat varius porttitor. Curabitur elementum sed enim ultrices imperdiet. - -In ut lobortis lacus, nec luctus arcu. Vivamus condimentum sapien a ipsum malesuada sodales. Donec et vestibulum risus. Integer dictum euismod eros id tincidunt. Aliquam sagittis leo vitae consequat fermentum. Donec maximus ex eu ex iaculis porta. Praesent pharetra lacinia risus, et eleifend diam commodo non. Sed feugiat ipsum ut orci sagittis, quis faucibus lectus blandit. Sed tellus quam, gravida vitae laoreet quis, tempus lobortis dui. Vivamus semper accumsan ullamcorper. Praesent tempus pretium eros, non elementum risus. Pellentesque odio quam, auctor quis ex non, vulputate egestas dolor. Nunc luctus enim ut justo sodales consectetur. Sed aliquet a mauris vel posuere. - -Donec luctus lectus efficitur, blandit nisi vitae, dignissim tellus. Pellentesque euismod pharetra augue gravida hendrerit. Quisque nisi mi, mattis ac nisi non, maximus malesuada ante. Nulla lobortis, diam eu ornare ornare, tellus enim feugiat arcu, non vestibulum tortor nunc eu justo. Integer blandit felis justo, eu semper est scelerisque vel. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nam ultricies, nisi vel elementum commodo, nisl dolor tincidunt magna, sed varius est nunc at lectus. Aliquam dolor tortor, sodales placerat ultricies quis, sodales quis sapien. Duis ullamcorper sollicitudin risus at mattis. Integer consequat et nunc at condimentum. Pellentesque cursus congue augue, non suscipit lectus sodales ut. Nam a mi bibendum, blandit nisl eu, accumsan nunc. Aliquam a ex mauris. Sed nec sem quis arcu dignissim tempus eget et turpis. Ut sed ex nec ipsum ultrices lobortis. - -Pellentesque rhoncus pharetra eros, non mollis nisi pretium non. Mauris accumsan quis odio quis euismod. Maecenas ultrices, augue et aliquam tincidunt, erat tellus ornare ligula, quis ultrices turpis nibh vel justo. Fusce gravida odio tellus. In a congue diam. Mauris consequat ex id leo lacinia dictum. Fusce id sem sodales, ultrices sapien ac, convallis orci. Donec gravida nunc sit amet nisi hendrerit, sed porta enim aliquam. In hac habitasse platea dictumst. Cras a orci felis. Curabitur non felis nec urna maximus auctor ut ut nisi. Curabitur at turpis eleifend, blandit eros at, molestie odio. Phasellus euismod neque augue. - -Integer egestas maximus leo eu facilisis. Nunc rhoncus dignissim lectus eu lacinia. Praesent lacinia urna porttitor aliquam condimentum. Nulla eu eros dictum, dictum nunc vitae, sagittis nibh. Integer ante neque, consequat nec sollicitudin id, consectetur vitae dolor. Nullam volutpat sem orci, quis viverra magna auctor a. Suspendisse potenti. Maecenas commodo sed neque pellentesque vehicula. Sed luctus nisl risus, elementum semper purus interdum vel. Ut pulvinar, massa sit amet venenatis placerat, nunc lacus hendrerit odio, non aliquet nunc risus eu lectus. Maecenas feugiat semper ligula, id lobortis sem porta eu. Integer posuere elit magna, at mollis eros bibendum et. Ut imperdiet purus vel nulla aliquam maximus. Morbi sodales purus tellus, a rhoncus sem rutrum sit amet. Quisque risus sem, laoreet nec convallis nec, rutrum vitae justo. - diff --git a/tests/reference/guiBuild_Tool_Step1_Lorem_Ipsum.nwd b/tests/reference/guiBuild_Tool_Step1_Lorem_Ipsum.nwd deleted file mode 100644 index 91e2f5c3..00000000 --- a/tests/reference/guiBuild_Tool_Step1_Lorem_Ipsum.nwd +++ /dev/null @@ -1,109 +0,0 @@ -#! Lorem Ipsum - -**By lipsum.com** - -“Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit…” - -“There is no one who loves pain itself, who seeks after it and wants to have it, simply because it is pain…” - - - -Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of “de Finibus Bonorum et Malorum” (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, “Lorem ipsum dolor sit amet..”, comes from a line in section 1.10.32. - -The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from “de Finibus Bonorum et Malorum” by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham. - -##! Prologue - - -_Lorem Ipsum_ is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. - -# Act One - -“Fusce maximus felis libero” - -## Chapter One - - - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam. Praesent magna nunc, lacinia sit amet quam eget, aliquet ultrices justo. Morbi ornare enim et lorem rutrum finibus ut eu dolor. Aliquam a orci odio. Ut ultrices sem quis massa placerat, eget mollis nisl cursus. Cras vel sagittis justo. Ut non ultricies leo. Maecenas rutrum velit in est varius, et egestas massa pulvinar. - -### Scene One - - - -Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur. Donec quis ante nunc. Mauris ut leo ipsum. Vestibulum est neque, hendrerit nec neque a, ullamcorper lobortis tellus. Fusce sollicitudin purus quis congue bibendum. Aliquam condimentum ipsum tristique blandit tristique. Donec pulvinar neque ac suscipit malesuada. - -Aliquam ut nisl arcu. Ut ultricies, lorem dignissim rutrum convallis, risus orci tempus lectus, congue feugiat sem lectus vitae odio. Duis sit amet justo finibus, hendrerit nulla at, ullamcorper enim. Praesent vel tellus sit amet tellus vulputate bibendum. Morbi eleifend sagittis sem, ac volutpat ante congue non. In hac habitasse platea dictumst. Morbi lobortis fermentum elit, dignissim sagittis ligula volutpat lacinia. Vestibulum eu interdum odio. Integer ac purus commodo metus congue tempor non at urna. Sed eget tortor vel quam viverra egestas. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Donec non convallis mauris, ac feugiat ex. - -#### Scene One, Section Two - -Integer vel libero ipsum. Donec varius aliquam libero, sit amet commodo urna hendrerit non. Nullam quis erat mollis nunc viverra volutpat tincidunt in odio. Nam vitae quam sem. Aliquam suscipit nulla non lorem pharetra semper. Ut suscipit erat eu ligula accumsan ultrices. Phasellus nisl tellus, placerat sed laoreet id, consectetur nec dolor. Sed fringilla ipsum id dapibus posuere. Aenean finibus pharetra tincidunt. Ut molestie malesuada nulla, id posuere lorem tincidunt eu. Aliquam tempor eros a est vulputate, scelerisque pulvinar ipsum fermentum. In hac habitasse platea dictumst. - -Curabitur congue, justo quis interdum fermentum, tellus nulla imperdiet sapien, eu interdum enim tellus condimentum metus. Vivamus nunc velit, dignissim ut ultrices sit amet, ultricies quis enim. Donec ut vestibulum neque. Vivamus semper neque id ex ullamcorper varius. Fusce mattis nibh viverra lorem sagittis, et tempor arcu congue. Suspendisse sit amet felis sed urna facilisis mattis eget vitae arcu. Proin eu magna hendrerit, tristique sem maximus, placerat diam. Nulla tristique sed velit sit amet varius. Etiam vel ornare magna, in vulputate arcu. Cras velit orci, tincidunt sed volutpat cursus, bibendum vel sem. Nunc vulputate pharetra tortor, ac consectetur neque tincidunt sit amet. Nulla ornare mi sed mi dignissim ultricies. Ut tincidunt bibendum mauris, sed elementum ex vulputate vel. Mauris fermentum, felis nec vehicula congue, felis lorem facilisis erat, a dictum dolor augue vitae quam. Maecenas rutrum tortor nec consequat eleifend. - -### Scene Two - - - -Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci. Vestibulum facilisis bibendum aliquam. Aliquam posuere, turpis ac bibendum varius, sem tellus venenatis risus, in elementum massa enim ac lorem. Integer in sem ac diam blandit ultricies ut in nulla. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam sit amet erat est. Curabitur vitae cursus justo, sit amet placerat dolor. Vivamus eu felis hendrerit, tincidunt massa rutrum, maximus arcu. Pellentesque commodo justo odio, vel rutrum nulla tincidunt eu. Integer non neque condimentum, convallis diam non, varius ligula. Aliquam eget sapien mauris. Aenean pharetra nunc nisi, vel maximus ante tristique sit amet. Aliquam risus metus, interdum non odio eu, consectetur lacinia sapien. - -Proin vitae gravida nisl. Integer viverra orci turpis, sit amet pretium ligula facilisis consequat. Nulla interdum commodo metus, mollis consequat dui tincidunt et. Proin consequat bibendum justo id commodo. Fusce fermentum nunc turpis, eu vestibulum risus feugiat ut. Sed scelerisque vel ligula ut interdum. Suspendisse ac blandit ligula, sagittis fringilla dolor. In tincidunt convallis diam et ornare. Aenean id dignissim est, ut rhoncus quam. Donec vitae nisl velit. In convallis nibh ut augue dignissim, eu elementum quam cursus. Phasellus in lectus lorem. Curabitur in pellentesque nisi, at gravida sapien. Sed cursus justo volutpat lacus placerat, sit amet dignissim turpis commodo. Aliquam vitae orci eget nulla posuere condimentum in ut felis. - -Nulla accumsan ante in pulvinar efficitur. Nulla non velit quis urna hendrerit bibendum. Suspendisse ultrices ante eu justo malesuada, sed fermentum enim rutrum. Nunc fermentum pharetra felis, vitae sollicitudin quam rutrum porta. Aliquam fringilla velit a mi laoreet, et luctus est rutrum. In gravida non ipsum sit amet tempus. Curabitur et eleifend purus. Nulla facilisi. - -#### Scene Two, Section Two - -Suspendisse potenti. Fusce tempus lorem nec laoreet suscipit. Fusce vulputate nisl ac diam tincidunt, nec malesuada quam pellentesque. Maecenas congue, tellus quis commodo rutrum, magna leo egestas arcu, quis suscipit ex risus id ligula. Suspendisse potenti. Morbi blandit lacus vitae laoreet vulputate. Donec vitae tellus eleifend, lobortis eros eu, tincidunt enim. Nullam et ullamcorper nisi. Vivamus tellus ex, lobortis quis rutrum ut, dapibus sit amet turpis. Phasellus pellentesque metus diam, commodo tristique ante commodo ac. Ut mollis ipsum nec diam blandit sollicitudin. Duis bibendum lacus nec commodo dapibus. Sed condimentum luctus ante, id ultricies urna varius nec. Nam convallis magna nec bibendum ultrices. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed auctor pharetra quam, vitae porta ex bibendum eu. - -Vivamus ut venenatis lectus. Phasellus nec elit id sem dictum ornare. Quisque feugiat, diam eget sagittis ultricies, orci turpis efficitur nisi, et fringilla justo odio nec nibh. In hac habitasse platea dictumst. Sed tempus bibendum feugiat. Etiam luctus mauris arcu, non interdum ipsum ultrices id. Vivamus blandit urna sit amet scelerisque vulputate. Quisque in metus eget massa rutrum dictum sit amet sed nulla. Vivamus vel efficitur dolor. - -Ut et consequat enim, quis ornare nibh. In lectus neque, mollis et suscipit et, vestibulum vitae augue. Praesent id ante sit amet odio venenatis placerat a at erat. Sed sed metus sed nisi dictum varius. Integer tincidunt fermentum purus ac porta. Fusce porttitor non risus eget tristique. Donec augue nunc, maximus at fermentum vel, varius et neque. Ut sed consectetur mauris. Quisque ipsum enim, porttitor vitae imperdiet sit amet, tempor et mauris. Aliquam malesuada tincidunt lectus quis blandit. Sed commodo orci felis, quis ultrices tellus facilisis sed. Nunc vel varius est. Duis ullamcorper eu metus in pulvinar. Morbi at sapien dictum, rutrum mauris eget, interdum tellus. - -## Chapter Two - - - -Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue. Vestibulum pharetra fermentum leo, sed faucibus eros placerat quis. In hac habitasse platea dictumst. Donec metus massa, rutrum quis consequat et, tincidunt ac felis. Duis mollis metus ac nunc tincidunt blandit. Ut aliquet velit eu odio pharetra condimentum. Integer rutrum lacus orci, id venenatis libero accumsan at. - -### Scene Three - - - -Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Aenean tincidunt lacus vitae nibh elementum eleifend. Sed rutrum condimentum sem quis blandit. Duis imperdiet libero metus, quis convallis quam faucibus a. Nulla ligula est, semper quis sollicitudin et, pretium id justo. Curabitur pharetra risus eget consectetur commodo. Duis mattis arcu non est condimentum, id venenatis risus volutpat. Pellentesque aliquet mauris non mauris porttitor ultrices. Phasellus ut vestibulum mi. Suspendisse malesuada metus lorem, a malesuada orci rhoncus a. Praesent euismod convallis ante, lacinia tincidunt ex egestas id. Praesent sit amet efficitur sapien. Morbi tincidunt volutpat nunc sed dictum. Aliquam ultrices metus id fermentum lobortis. - -Pellentesque id sagittis dui. Praesent ut nisi sit amet libero euismod ornare. Vestibulum vehicula, lorem eget aliquet imperdiet, eros nulla iaculis mi, vel bibendum est dui sed orci. Nullam vitae lorem rutrum, euismod lacus id, ullamcorper lectus. Duis nec commodo mi, a fringilla diam. Vestibulum molestie nibh tristique, viverra augue non, aliquet metus. Phasellus a tellus ac nisl tempor aliquet. Nulla vitae sapien rutrum augue ornare ultrices a quis nisi. Sed pulvinar tincidunt ex. Fusce vel sem vitae ante pellentesque lobortis. - -Maecenas ullamcorper lacus nec turpis finibus aliquet eget rutrum augue. Integer lorem erat, faucibus non lacus lacinia, pulvinar egestas felis. Proin rutrum nunc eget nulla varius, id blandit mauris tincidunt. Donec sit amet ullamcorper nisi, ut efficitur mi. Aliquam aliquet, nulla eget rhoncus tristique, justo lorem consectetur dui, id ornare leo odio sed tellus. Curabitur interdum velit a turpis condimentum venenatis. Nunc rhoncus sem ac augue auctor, nec malesuada ex fringilla. Vestibulum egestas diam sed leo consectetur vulputate quis eget enim. Nam tincidunt metus sit amet maximus ullamcorper. Sed placerat velit vitae massa efficitur viverra. Etiam eleifend dignissim ante, sed luctus nisl tristique a. In vestibulum pharetra dolor in molestie. Vivamus auctor massa ac magna imperdiet, sit amet iaculis turpis finibus. - -Aenean dapibus vulputate purus, sit amet tempor nunc suscipit consequat. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Mauris auctor congue eros, non pellentesque neque dapibus ac. Vestibulum non leo nec urna lacinia eleifend quis et diam. Praesent eu nisi magna. Nulla at magna massa. Suspendisse porta varius scelerisque. Duis at auctor dolor, non dapibus urna. Nunc venenatis feugiat magna non molestie. Aliquam non ornare ex. Quisque eu ultrices velit, quis pellentesque eros. Phasellus eleifend, elit id imperdiet aliquam, nulla quam molestie turpis, at egestas odio ante et tortor. Suspendisse fringilla condimentum justo, at aliquet odio aliquam ac. - -### Scene Four - - - -Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo. Nullam viverra dui et auctor pretium. Ut ullamcorper velit urna, sed imperdiet massa convallis a. Suspendisse efficitur, ipsum nec cursus pulvinar, eros urna posuere diam, nec elementum mi felis vitae sapien. - -Duis efficitur metus pulvinar, molestie magna eget, feugiat dui. Fusce convallis vehicula ipsum convallis blandit. Duis eros risus, malesuada eu imperdiet in, hendrerit ac metus. Vestibulum id justo gravida, dignissim nibh non, iaculis diam. Fusce accumsan est ut massa porta ultricies. Nulla vitae justo in tortor laoreet mollis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin eu libero justo. Vivamus aliquet placerat est, et auctor eros posuere venenatis. Nunc quam diam, tincidunt ac aliquet in, fermentum sit amet lectus. Proin commodo tincidunt blandit. Quisque erat arcu, semper nec dui non, consectetur gravida ipsum. Nullam pretium consectetur elit at condimentum. - -Etiam sagittis, erat vitae accumsan tempor, neque augue scelerisque nulla, ut ultrices justo urna sit amet augue. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean at pulvinar tortor. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Cras vel porta quam. Nullam eu mauris mollis, vehicula justo vel, placerat sapien. Phasellus viverra elit et vestibulum pharetra. Vestibulum commodo fermentum leo, eu porta nisi aliquam eget. Nulla tempus porttitor nisi nec mollis. Nam non mollis turpis. Nam finibus leo a bibendum tincidunt. Donec commodo velit magna, ac semper sapien mattis id. Proin sem velit, lobortis quis ultricies id, pharetra et lectus. Vestibulum condimentum neque vitae mi dapibus mollis. Mauris luctus vel sapien vitae hendrerit. - -Aenean vestibulum magna placerat fermentum tempus. Nam auctor condimentum nunc, in elementum quam ornare a. Etiam in ipsum elit. Proin pharetra, dolor sollicitudin pellentesque congue, lorem dolor ultricies magna, non iaculis risus nisl dictum diam. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Vivamus vel euismod nibh, et lobortis dolor. Maecenas dui odio, gravida nec molestie ut, feugiat ut arcu. Pellentesque risus sapien, gravida a convallis quis, ullamcorper porttitor sapien. - -Donec ipsum eros, vestibulum sit amet cursus eget, iaculis quis dolor. Pellentesque magna augue, tristique dapibus mi vitae, molestie venenatis enim. Nam malesuada, turpis volutpat rhoncus ullamcorper, justo est eleifend orci, ut luctus risus ex rutrum arcu. Sed mi elit, feugiat rhoncus ornare sed, porta id leo. Pellentesque feugiat nulla tincidunt erat suscipit, eu congue lacus hendrerit. Morbi pulvinar enim sed consequat auctor. Ut eleifend enim sem, vitae euismod ex ultricies sit amet. Curabitur eu efficitur nisi, suscipit finibus sapien. In sodales blandit erat, vestibulum pulvinar ante volutpat nec. Vivamus dictum non libero at molestie. Donec sit amet neque in ante convallis pretium. Nunc vel iaculis dui. - -Phasellus eu nunc ut nunc faucibus laoreet. Aliquam at magna risus. Praesent lobortis, risus finibus semper varius, magna purus vestibulum eros, at pulvinar sapien enim a ex. In scelerisque malesuada ex, sit amet egestas neque condimentum sed. Praesent vulputate efficitur massa. Cras at accumsan ligula. In elementum lectus eget blandit dictum. Nam vitae libero ut justo eleifend rutrum ac nec arcu. Aliquam sodales in quam congue vestibulum. Aliquam in accumsan sapien. Quisque lobortis nisl nisi, vitae bibendum turpis efficitur sed. Vestibulum tempor nulla eget nisi convallis, blandit sagittis ipsum convallis. Donec odio nibh, ultrices quis odio in, mollis euismod libero. - -### Scene Five - - - -Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. In sed felis auctor, rhoncus dui ac, consequat dolor. Integer volutpat libero sed nisl aliquet varius. Suspendisse et lorem sapien. Proin id ultrices nibh, ac suscipit diam. Suspendisse placerat varius porttitor. Curabitur elementum sed enim ultrices imperdiet. - -In ut lobortis lacus, nec luctus arcu. Vivamus condimentum sapien a ipsum malesuada sodales. Donec et vestibulum risus. Integer dictum euismod eros id tincidunt. Aliquam sagittis leo vitae consequat fermentum. Donec maximus ex eu ex iaculis porta. Praesent pharetra lacinia risus, et eleifend diam commodo non. Sed feugiat ipsum ut orci sagittis, quis faucibus lectus blandit. Sed tellus quam, gravida vitae laoreet quis, tempus lobortis dui. Vivamus semper accumsan ullamcorper. Praesent tempus pretium eros, non elementum risus. Pellentesque odio quam, auctor quis ex non, vulputate egestas dolor. Nunc luctus enim ut justo sodales consectetur. Sed aliquet a mauris vel posuere. - -Donec luctus lectus efficitur, blandit nisi vitae, dignissim tellus. Pellentesque euismod pharetra augue gravida hendrerit. Quisque nisi mi, mattis ac nisi non, maximus malesuada ante. Nulla lobortis, diam eu ornare ornare, tellus enim feugiat arcu, non vestibulum tortor nunc eu justo. Integer blandit felis justo, eu semper est scelerisque vel. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nam ultricies, nisi vel elementum commodo, nisl dolor tincidunt magna, sed varius est nunc at lectus. Aliquam dolor tortor, sodales placerat ultricies quis, sodales quis sapien. Duis ullamcorper sollicitudin risus at mattis. Integer consequat et nunc at condimentum. Pellentesque cursus congue augue, non suscipit lectus sodales ut. Nam a mi bibendum, blandit nisl eu, accumsan nunc. Aliquam a ex mauris. Sed nec sem quis arcu dignissim tempus eget et turpis. Ut sed ex nec ipsum ultrices lobortis. - -Pellentesque rhoncus pharetra eros, non mollis nisi pretium non. Mauris accumsan quis odio quis euismod. Maecenas ultrices, augue et aliquam tincidunt, erat tellus ornare ligula, quis ultrices turpis nibh vel justo. Fusce gravida odio tellus. In a congue diam. Mauris consequat ex id leo lacinia dictum. Fusce id sem sodales, ultrices sapien ac, convallis orci. Donec gravida nunc sit amet nisi hendrerit, sed porta enim aliquam. In hac habitasse platea dictumst. Cras a orci felis. Curabitur non felis nec urna maximus auctor ut ut nisi. Curabitur at turpis eleifend, blandit eros at, molestie odio. Phasellus euismod neque augue. - -Integer egestas maximus leo eu facilisis. Nunc rhoncus dignissim lectus eu lacinia. Praesent lacinia urna porttitor aliquam condimentum. Nulla eu eros dictum, dictum nunc vitae, sagittis nibh. Integer ante neque, consequat nec sollicitudin id, consectetur vitae dolor. Nullam volutpat sem orci, quis viverra magna auctor a. Suspendisse potenti. Maecenas commodo sed neque pellentesque vehicula. Sed luctus nisl risus, elementum semper purus interdum vel. Ut pulvinar, massa sit amet venenatis placerat, nunc lacus hendrerit odio, non aliquet nunc risus eu lectus. Maecenas feugiat semper ligula, id lobortis sem porta eu. Integer posuere elit magna, at mollis eros bibendum et. Ut imperdiet purus vel nulla aliquam maximus. Morbi sodales purus tellus, a rhoncus sem rutrum sit amet. Quisque risus sem, laoreet nec convallis nec, rutrum vitae justo. - diff --git a/tests/reference/guiBuild_Tool_Step2_Lorem_Ipsum.fodt b/tests/reference/guiBuild_Tool_Step2_Lorem_Ipsum.fodt deleted file mode 100644 index cf329eb6..00000000 --- a/tests/reference/guiBuild_Tool_Step2_Lorem_Ipsum.fodt +++ /dev/null @@ -1,209 +0,0 @@ - - - - 2023-05-29T22:11:22 - novelWriter/2.0.7 - lipsum.com - 40 - P0DT0H31M45S - Lorem Ipsum - 2023-05-29T22:11:22 - lipsum.com - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Lorem Ipsum / lipsum.com / 2 - - - - - - - - - Lorem Ipsum - By lipsum.com - “Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit…” - “There is no one who loves pain itself, who seeks after it and wants to have it, simply because it is pain…” - Comment: Exctracted from the lipsum.com website. - Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of “de Finibus Bonorum et Malorum” (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, “Lorem ipsum dolor sit amet..”, comes from a line in section 1.10.32. - The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from “de Finibus Bonorum et Malorum” by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham. - Prologue - Synopsis: Explanation from the lipsum.com website. - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. - Act One - “Fusce maximus felis libero” - Chapter One: Chapter One - Point of View: Bod - Plot: Main - Locations: Europe - Synopsis: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam. - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam. Praesent magna nunc, lacinia sit amet quam eget, aliquet ultrices justo. Morbi ornare enim et lorem rutrum finibus ut eu dolor. Aliquam a orci odio. Ut ultrices sem quis massa placerat, eget mollis nisl cursus. Cras vel sagittis justo. Ut non ultricies leo. Maecenas rutrum velit in est varius, et egestas massa pulvinar. - Scene 1.1: Scene One - Point of View: Bod - Plot: Main - Locations: Europe - Synopsis: Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur. - Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur. Donec quis ante nunc. Mauris ut leo ipsum. Vestibulum est neque, hendrerit nec neque a, ullamcorper lobortis tellus. Fusce sollicitudin purus quis congue bibendum. Aliquam condimentum ipsum tristique blandit tristique. Donec pulvinar neque ac suscipit malesuada. - Aliquam ut nisl arcu. Ut ultricies, lorem dignissim rutrum convallis, risus orci tempus lectus, congue feugiat sem lectus vitae odio. Duis sit amet justo finibus, hendrerit nulla at, ullamcorper enim. Praesent vel tellus sit amet tellus vulputate bibendum. Morbi eleifend sagittis sem, ac volutpat ante congue non. In hac habitasse platea dictumst. Morbi lobortis fermentum elit, dignissim sagittis ligula volutpat lacinia. Vestibulum eu interdum odio. Integer ac purus commodo metus congue tempor non at urna. Sed eget tortor vel quam viverra egestas. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Donec non convallis mauris, ac feugiat ex. - 1.1.1: Scene One, Section Two - Integer vel libero ipsum. Donec varius aliquam libero, sit amet commodo urna hendrerit non. Nullam quis erat mollis nunc viverra volutpat tincidunt in odio. Nam vitae quam sem. Aliquam suscipit nulla non lorem pharetra semper. Ut suscipit erat eu ligula accumsan ultrices. Phasellus nisl tellus, placerat sed laoreet id, consectetur nec dolor. Sed fringilla ipsum id dapibus posuere. Aenean finibus pharetra tincidunt. Ut molestie malesuada nulla, id posuere lorem tincidunt eu. Aliquam tempor eros a est vulputate, scelerisque pulvinar ipsum fermentum. In hac habitasse platea dictumst. - Curabitur congue, justo quis interdum fermentum, tellus nulla imperdiet sapien, eu interdum enim tellus condimentum metus. Vivamus nunc velit, dignissim ut ultrices sit amet, ultricies quis enim. Donec ut vestibulum neque. Vivamus semper neque id ex ullamcorper varius. Fusce mattis nibh viverra lorem sagittis, et tempor arcu congue. Suspendisse sit amet felis sed urna facilisis mattis eget vitae arcu. Proin eu magna hendrerit, tristique sem maximus, placerat diam. Nulla tristique sed velit sit amet varius. Etiam vel ornare magna, in vulputate arcu. Cras velit orci, tincidunt sed volutpat cursus, bibendum vel sem. Nunc vulputate pharetra tortor, ac consectetur neque tincidunt sit amet. Nulla ornare mi sed mi dignissim ultricies. Ut tincidunt bibendum mauris, sed elementum ex vulputate vel. Mauris fermentum, felis nec vehicula congue, felis lorem facilisis erat, a dictum dolor augue vitae quam. Maecenas rutrum tortor nec consequat eleifend. - Scene 1.2: Scene Two - Point of View: Bod - Plot: Main - Locations: Europe - Synopsis: Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci. - Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci. Vestibulum facilisis bibendum aliquam. Aliquam posuere, turpis ac bibendum varius, sem tellus venenatis risus, in elementum massa enim ac lorem. Integer in sem ac diam blandit ultricies ut in nulla. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam sit amet erat est. Curabitur vitae cursus justo, sit amet placerat dolor. Vivamus eu felis hendrerit, tincidunt massa rutrum, maximus arcu. Pellentesque commodo justo odio, vel rutrum nulla tincidunt eu. Integer non neque condimentum, convallis diam non, varius ligula. Aliquam eget sapien mauris. Aenean pharetra nunc nisi, vel maximus ante tristique sit amet. Aliquam risus metus, interdum non odio eu, consectetur lacinia sapien. - Proin vitae gravida nisl. Integer viverra orci turpis, sit amet pretium ligula facilisis consequat. Nulla interdum commodo metus, mollis consequat dui tincidunt et. Proin consequat bibendum justo id commodo. Fusce fermentum nunc turpis, eu vestibulum risus feugiat ut. Sed scelerisque vel ligula ut interdum. Suspendisse ac blandit ligula, sagittis fringilla dolor. In tincidunt convallis diam et ornare. Aenean id dignissim est, ut rhoncus quam. Donec vitae nisl velit. In convallis nibh ut augue dignissim, eu elementum quam cursus. Phasellus in lectus lorem. Curabitur in pellentesque nisi, at gravida sapien. Sed cursus justo volutpat lacus placerat, sit amet dignissim turpis commodo. Aliquam vitae orci eget nulla posuere condimentum in ut felis. - Nulla accumsan ante in pulvinar efficitur. Nulla non velit quis urna hendrerit bibendum. Suspendisse ultrices ante eu justo malesuada, sed fermentum enim rutrum. Nunc fermentum pharetra felis, vitae sollicitudin quam rutrum porta. Aliquam fringilla velit a mi laoreet, et luctus est rutrum. In gravida non ipsum sit amet tempus. Curabitur et eleifend purus. Nulla facilisi. - 1.2.1: Scene Two, Section Two - Suspendisse potenti. Fusce tempus lorem nec laoreet suscipit. Fusce vulputate nisl ac diam tincidunt, nec malesuada quam pellentesque. Maecenas congue, tellus quis commodo rutrum, magna leo egestas arcu, quis suscipit ex risus id ligula. Suspendisse potenti. Morbi blandit lacus vitae laoreet vulputate. Donec vitae tellus eleifend, lobortis eros eu, tincidunt enim. Nullam et ullamcorper nisi. Vivamus tellus ex, lobortis quis rutrum ut, dapibus sit amet turpis. Phasellus pellentesque metus diam, commodo tristique ante commodo ac. Ut mollis ipsum nec diam blandit sollicitudin. Duis bibendum lacus nec commodo dapibus. Sed condimentum luctus ante, id ultricies urna varius nec. Nam convallis magna nec bibendum ultrices. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed auctor pharetra quam, vitae porta ex bibendum eu. - Vivamus ut venenatis lectus. Phasellus nec elit id sem dictum ornare. Quisque feugiat, diam eget sagittis ultricies, orci turpis efficitur nisi, et fringilla justo odio nec nibh. In hac habitasse platea dictumst. Sed tempus bibendum feugiat. Etiam luctus mauris arcu, non interdum ipsum ultrices id. Vivamus blandit urna sit amet scelerisque vulputate. Quisque in metus eget massa rutrum dictum sit amet sed nulla. Vivamus vel efficitur dolor. - Ut et consequat enim, quis ornare nibh. In lectus neque, mollis et suscipit et, vestibulum vitae augue. Praesent id ante sit amet odio venenatis placerat a at erat. Sed sed metus sed nisi dictum varius. Integer tincidunt fermentum purus ac porta. Fusce porttitor non risus eget tristique. Donec augue nunc, maximus at fermentum vel, varius et neque. Ut sed consectetur mauris. Quisque ipsum enim, porttitor vitae imperdiet sit amet, tempor et mauris. Aliquam malesuada tincidunt lectus quis blandit. Sed commodo orci felis, quis ultrices tellus facilisis sed. Nunc vel varius est. Duis ullamcorper eu metus in pulvinar. Morbi at sapien dictum, rutrum mauris eget, interdum tellus. - Why do we use it? - Comment: Exctracted from the lipsum.com website. - It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout. - The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, as opposed to using 'Content here, content here', making it look like readable English. - Many desktop publishing packages and web page editors now use Lorem Ipsum as their default model text, and a search for 'lorem ipsum' will uncover many web sites still in their infancy. Various versions have evolved over the years, sometimes by accident, sometimes on purpose (injected humour and the like). - Chapter Two: Chapter Two - Point of View: Bod - Plot: Main - Locations: Europe - Synopsis: Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue. - Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue. Vestibulum pharetra fermentum leo, sed faucibus eros placerat quis. In hac habitasse platea dictumst. Donec metus massa, rutrum quis consequat et, tincidunt ac felis. Duis mollis metus ac nunc tincidunt blandit. Ut aliquet velit eu odio pharetra condimentum. Integer rutrum lacus orci, id venenatis libero accumsan at. - Scene 2.1: Scene Three - Point of View: Bod - Plot: Main - Locations: Europe - Synopsis: Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. - Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Aenean tincidunt lacus vitae nibh elementum eleifend. Sed rutrum condimentum sem quis blandit. Duis imperdiet libero metus, quis convallis quam faucibus a. Nulla ligula est, semper quis sollicitudin et, pretium id justo. Curabitur pharetra risus eget consectetur commodo. Duis mattis arcu non est condimentum, id venenatis risus volutpat. Pellentesque aliquet mauris non mauris porttitor ultrices. Phasellus ut vestibulum mi. Suspendisse malesuada metus lorem, a malesuada orci rhoncus a. Praesent euismod convallis ante, lacinia tincidunt ex egestas id. Praesent sit amet efficitur sapien. Morbi tincidunt volutpat nunc sed dictum. Aliquam ultrices metus id fermentum lobortis. - Pellentesque id sagittis dui. Praesent ut nisi sit amet libero euismod ornare. Vestibulum vehicula, lorem eget aliquet imperdiet, eros nulla iaculis mi, vel bibendum est dui sed orci. Nullam vitae lorem rutrum, euismod lacus id, ullamcorper lectus. Duis nec commodo mi, a fringilla diam. Vestibulum molestie nibh tristique, viverra augue non, aliquet metus. Phasellus a tellus ac nisl tempor aliquet. Nulla vitae sapien rutrum augue ornare ultrices a quis nisi. Sed pulvinar tincidunt ex. Fusce vel sem vitae ante pellentesque lobortis. - Maecenas ullamcorper lacus nec turpis finibus aliquet eget rutrum augue. Integer lorem erat, faucibus non lacus lacinia, pulvinar egestas felis. Proin rutrum nunc eget nulla varius, id blandit mauris tincidunt. Donec sit amet ullamcorper nisi, ut efficitur mi. Aliquam aliquet, nulla eget rhoncus tristique, justo lorem consectetur dui, id ornare leo odio sed tellus. Curabitur interdum velit a turpis condimentum venenatis. Nunc rhoncus sem ac augue auctor, nec malesuada ex fringilla. Vestibulum egestas diam sed leo consectetur vulputate quis eget enim. Nam tincidunt metus sit amet maximus ullamcorper. Sed placerat velit vitae massa efficitur viverra. Etiam eleifend dignissim ante, sed luctus nisl tristique a. In vestibulum pharetra dolor in molestie. Vivamus auctor massa ac magna imperdiet, sit amet iaculis turpis finibus. - Aenean dapibus vulputate purus, sit amet tempor nunc suscipit consequat. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Mauris auctor congue eros, non pellentesque neque dapibus ac. Vestibulum non leo nec urna lacinia eleifend quis et diam. Praesent eu nisi magna. Nulla at magna massa. Suspendisse porta varius scelerisque. Duis at auctor dolor, non dapibus urna. Nunc venenatis feugiat magna non molestie. Aliquam non ornare ex. Quisque eu ultrices velit, quis pellentesque eros. Phasellus eleifend, elit id imperdiet aliquam, nulla quam molestie turpis, at egestas odio ante et tortor. Suspendisse fringilla condimentum justo, at aliquet odio aliquam ac. - Scene 2.2: Scene Four - Point of View: Bod - Plot: Main - Locations: Europe - Synopsis: Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo. - Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo. Nullam viverra dui et auctor pretium. Ut ullamcorper velit urna, sed imperdiet massa convallis a. Suspendisse efficitur, ipsum nec cursus pulvinar, eros urna posuere diam, nec elementum mi felis vitae sapien. - Duis efficitur metus pulvinar, molestie magna eget, feugiat dui. Fusce convallis vehicula ipsum convallis blandit. Duis eros risus, malesuada eu imperdiet in, hendrerit ac metus. Vestibulum id justo gravida, dignissim nibh non, iaculis diam. Fusce accumsan est ut massa porta ultricies. Nulla vitae justo in tortor laoreet mollis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin eu libero justo. Vivamus aliquet placerat est, et auctor eros posuere venenatis. Nunc quam diam, tincidunt ac aliquet in, fermentum sit amet lectus. Proin commodo tincidunt blandit. Quisque erat arcu, semper nec dui non, consectetur gravida ipsum. Nullam pretium consectetur elit at condimentum. - Etiam sagittis, erat vitae accumsan tempor, neque augue scelerisque nulla, ut ultrices justo urna sit amet augue. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean at pulvinar tortor. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Cras vel porta quam. Nullam eu mauris mollis, vehicula justo vel, placerat sapien. Phasellus viverra elit et vestibulum pharetra. Vestibulum commodo fermentum leo, eu porta nisi aliquam eget. Nulla tempus porttitor nisi nec mollis. Nam non mollis turpis. Nam finibus leo a bibendum tincidunt. Donec commodo velit magna, ac semper sapien mattis id. Proin sem velit, lobortis quis ultricies id, pharetra et lectus. Vestibulum condimentum neque vitae mi dapibus mollis. Mauris luctus vel sapien vitae hendrerit. - Aenean vestibulum magna placerat fermentum tempus. Nam auctor condimentum nunc, in elementum quam ornare a. Etiam in ipsum elit. Proin pharetra, dolor sollicitudin pellentesque congue, lorem dolor ultricies magna, non iaculis risus nisl dictum diam. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Vivamus vel euismod nibh, et lobortis dolor. Maecenas dui odio, gravida nec molestie ut, feugiat ut arcu. Pellentesque risus sapien, gravida a convallis quis, ullamcorper porttitor sapien. - Donec ipsum eros, vestibulum sit amet cursus eget, iaculis quis dolor. Pellentesque magna augue, tristique dapibus mi vitae, molestie venenatis enim. Nam malesuada, turpis volutpat rhoncus ullamcorper, justo est eleifend orci, ut luctus risus ex rutrum arcu. Sed mi elit, feugiat rhoncus ornare sed, porta id leo. Pellentesque feugiat nulla tincidunt erat suscipit, eu congue lacus hendrerit. Morbi pulvinar enim sed consequat auctor. Ut eleifend enim sem, vitae euismod ex ultricies sit amet. Curabitur eu efficitur nisi, suscipit finibus sapien. In sodales blandit erat, vestibulum pulvinar ante volutpat nec. Vivamus dictum non libero at molestie. Donec sit amet neque in ante convallis pretium. Nunc vel iaculis dui. - Phasellus eu nunc ut nunc faucibus laoreet. Aliquam at magna risus. Praesent lobortis, risus finibus semper varius, magna purus vestibulum eros, at pulvinar sapien enim a ex. In scelerisque malesuada ex, sit amet egestas neque condimentum sed. Praesent vulputate efficitur massa. Cras at accumsan ligula. In elementum lectus eget blandit dictum. Nam vitae libero ut justo eleifend rutrum ac nec arcu. Aliquam sodales in quam congue vestibulum. Aliquam in accumsan sapien. Quisque lobortis nisl nisi, vitae bibendum turpis efficitur sed. Vestibulum tempor nulla eget nisi convallis, blandit sagittis ipsum convallis. Donec odio nibh, ultrices quis odio in, mollis euismod libero. - Scene 2.3: Scene Five - Point of View: Bod - Plot: Main - Locations: Europe - Synopsis: Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. - Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. In sed felis auctor, rhoncus dui ac, consequat dolor. Integer volutpat libero sed nisl aliquet varius. Suspendisse et lorem sapien. Proin id ultrices nibh, ac suscipit diam. Suspendisse placerat varius porttitor. Curabitur elementum sed enim ultrices imperdiet. - In ut lobortis lacus, nec luctus arcu. Vivamus condimentum sapien a ipsum malesuada sodales. Donec et vestibulum risus. Integer dictum euismod eros id tincidunt. Aliquam sagittis leo vitae consequat fermentum. Donec maximus ex eu ex iaculis porta. Praesent pharetra lacinia risus, et eleifend diam commodo non. Sed feugiat ipsum ut orci sagittis, quis faucibus lectus blandit. Sed tellus quam, gravida vitae laoreet quis, tempus lobortis dui. Vivamus semper accumsan ullamcorper. Praesent tempus pretium eros, non elementum risus. Pellentesque odio quam, auctor quis ex non, vulputate egestas dolor. Nunc luctus enim ut justo sodales consectetur. Sed aliquet a mauris vel posuere. - Donec luctus lectus efficitur, blandit nisi vitae, dignissim tellus. Pellentesque euismod pharetra augue gravida hendrerit. Quisque nisi mi, mattis ac nisi non, maximus malesuada ante. Nulla lobortis, diam eu ornare ornare, tellus enim feugiat arcu, non vestibulum tortor nunc eu justo. Integer blandit felis justo, eu semper est scelerisque vel. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nam ultricies, nisi vel elementum commodo, nisl dolor tincidunt magna, sed varius est nunc at lectus. Aliquam dolor tortor, sodales placerat ultricies quis, sodales quis sapien. Duis ullamcorper sollicitudin risus at mattis. Integer consequat et nunc at condimentum. Pellentesque cursus congue augue, non suscipit lectus sodales ut. Nam a mi bibendum, blandit nisl eu, accumsan nunc. Aliquam a ex mauris. Sed nec sem quis arcu dignissim tempus eget et turpis. Ut sed ex nec ipsum ultrices lobortis. - Pellentesque rhoncus pharetra eros, non mollis nisi pretium non. Mauris accumsan quis odio quis euismod. Maecenas ultrices, augue et aliquam tincidunt, erat tellus ornare ligula, quis ultrices turpis nibh vel justo. Fusce gravida odio tellus. In a congue diam. Mauris consequat ex id leo lacinia dictum. Fusce id sem sodales, ultrices sapien ac, convallis orci. Donec gravida nunc sit amet nisi hendrerit, sed porta enim aliquam. In hac habitasse platea dictumst. Cras a orci felis. Curabitur non felis nec urna maximus auctor ut ut nisi. Curabitur at turpis eleifend, blandit eros at, molestie odio. Phasellus euismod neque augue. - Integer egestas maximus leo eu facilisis. Nunc rhoncus dignissim lectus eu lacinia. Praesent lacinia urna porttitor aliquam condimentum. Nulla eu eros dictum, dictum nunc vitae, sagittis nibh. Integer ante neque, consequat nec sollicitudin id, consectetur vitae dolor. Nullam volutpat sem orci, quis viverra magna auctor a. Suspendisse potenti. Maecenas commodo sed neque pellentesque vehicula. Sed luctus nisl risus, elementum semper purus interdum vel. Ut pulvinar, massa sit amet venenatis placerat, nunc lacus hendrerit odio, non aliquet nunc risus eu lectus. Maecenas feugiat semper ligula, id lobortis sem porta eu. Integer posuere elit magna, at mollis eros bibendum et. Ut imperdiet purus vel nulla aliquam maximus. Morbi sodales purus tellus, a rhoncus sem rutrum sit amet. Quisque risus sem, laoreet nec convallis nec, rutrum vitae justo. - Notes: Characters - Nobody Owens - Tag: Bod - Plot: Main - Pellentesque nec erat ut nulla posuere commodo. Curabitur nisi augue, imperdiet et porta imperdiet, efficitur id leo. Cras finibus arcu at nibh commodo congue. Proin suscipit placerat condimentum. Aenean ante enim, cursus id lorem a, blandit venenatis nibh. Maecenas suscipit porta elit, sit amet porta felis porttitor eu. Sed a dui nibh. Phasellus sed faucibus dui. Pellentesque felis nulla, ultrices non efficitur quis, rutrum id mi. Mauris tempus auctor nisl, in bibendum enim pellentesque sit amet. Proin nunc lacus, imperdiet nec posuere ac, interdum non lectus. - Suspendisse faucibus est auctor orci mollis luctus. Praesent quis sodales neque. Interdum et malesuada fames ac ante ipsum primis in faucibus. Donec sodales rutrum mattis. In in sem ornare, consequat nulla ac, convallis arcu. Duis ac metus id felis commodo commodo sit amet eget diam. Curabitur rhoncus lacinia leo at sodales. Etiam finibus porta diam a viverra. Praesent nisi urna, volutpat sit amet odio at, vehicula vehicula leo. In non enim eget nisl luctus commodo. Pellentesque pellentesque at lectus at luctus. Quisque nec felis bibendum, lacinia libero ut, lacinia eros. Integer finibus ultricies nibh sit amet placerat. - Nullam scelerisque velit et tortor congue vestibulum a at nisi. Vivamus sodales ut turpis a convallis. In dignissim nibh at luctus sodales. Etiam sit amet rhoncus massa. Phasellus ligula magna, sollicitudin non imperdiet sit amet, volutpat vel magna. Nunc vestibulum tempor lectus, sit amet porta nunc hendrerit in. Curabitur non odio sit amet massa tincidunt facilisis. Integer et luctus nunc, eget euismod leo. Praesent faucibus metus sed purus convallis scelerisque. Fusce viverra lorem et placerat malesuada. In at elit malesuada, ullamcorper risus vitae, sodales dolor. Donec quis elementum lectus. Quisque eu eros at dui imperdiet euismod ut id neque. - Notes: Plot - Main Plot - Tag: Main - Suspendisse vulputate malesuada pellentesque. Aenean sollicitudin cursus mi, vitae ultricies felis ullamcorper eu. Duis luctus risus mi, in accumsan velit cursus ut. Vestibulum eleifend leo in magna eleifend fermentum. Proin nec ornare elit. Phasellus nec interdum risus. In a volutpat augue, quis egestas justo. Morbi porta mauris mattis bibendum imperdiet. - Mauris ut erat eu lorem malesuada egestas vel vel urna. Maecenas ac semper quam. Maecenas aliquet metus non interdum mattis. Proin consectetur molestie ligula. Aliquam sollicitudin pulvinar urna a pellentesque. Suspendisse ultrices, est mattis scelerisque porta, nisi nisi laoreet nisl, non condimentum quam ante a velit. Proin scelerisque justo augue, nec laoreet ligula egestas at. Etiam enim quam, ultrices non accumsan hendrerit, elementum vel ligula. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Nam efficitur odio libero, in vestibulum arcu aliquam at. Cras non vehicula augue. Integer lobortis, est vitae aliquam facilisis, metus ligula aliquet eros, at porttitor sem tortor eget massa. Aliquam varius scelerisque neque sed gravida. Aenean eleifend lorem id ante elementum sollicitudin. Proin commodo massa a quam volutpat, mollis fermentum turpis efficitur. - Notes: World - Ancient Europe - Tag: Europe - Vivamus sodales risus ac accumsan posuere. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nunc vel enim felis. Vestibulum dignissim massa nunc, a auctor magna eleifend et. Proin dignissim sodales erat vitae convallis. Aliquam id tellus dui. Curabitur sollicitudin scelerisque ex sit amet posuere. Nam rutrum felis id rhoncus feugiat. Duis sagittis quam quis purus efficitur, quis rutrum odio iaculis. Maecenas semper ante turpis, at vulputate mi consectetur non. Sed rutrum nibh turpis, quis rhoncus purus ornare quis. Vestibulum at rutrum mauris. Integer dolor nisi, tincidunt eget vehicula ac, ultricies at ligula. - Aenean semper turpis quis varius rhoncus. Vivamus ac mi eget felis euismod vulputate. Nam eu tempus velit. Etiam ut est porta, finibus erat sit amet, consectetur felis. Nullam consequat felis ut lacus pharetra, in lobortis urna mollis. Nulla varius eros nec lorem rhoncus, sed venenatis risus ultrices. Phasellus pellentesque laoreet neque, ut ultricies lacus vulputate quis. In malesuada dui sit amet est interdum, eget consectetur mi gravida. Cras vel bibendum purus. Quisque commodo tempor arcu, non lacinia sem blandit eleifend. Quisque at neque gravida, porttitor metus a, suscipit diam. Quisque convallis sodales lacus et condimentum. Donec a suscipit diam. Pellentesque eget cursus neque. - Nunc ullamcorper magna quis elit condimentum rhoncus. Aenean dictum pulvinar dolor suscipit interdum. Aliquam elit massa, elementum nec cursus eu, maximus nec ipsum. Donec ullamcorper iaculis dolor eu commodo. Nunc eget tortor quis turpis consectetur varius. Vestibulum nec justo vel tellus venenatis condimentum. Duis auctor iaculis massa. Nunc risus magna, rutrum vitae eros non, tristique mollis enim. - - - diff --git a/tests/reference/guiBuild_Tool_Step2_Lorem_Ipsum.htm b/tests/reference/guiBuild_Tool_Step2_Lorem_Ipsum.htm deleted file mode 100644 index 0e672a68..00000000 --- a/tests/reference/guiBuild_Tool_Step2_Lorem_Ipsum.htm +++ /dev/null @@ -1,126 +0,0 @@ - - - - -Lorem Ipsum - - - -
-

Lorem Ipsum

-

By lipsum.com

-

“Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit…”

-

“There is no one who loves pain itself, who seeks after it and wants to have it, simply because it is pain…”

-

Comment: Exctracted from the lipsum.com website.

-

Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of “de Finibus Bonorum et Malorum” (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, “Lorem ipsum dolor sit amet..”, comes from a line in section 1.10.32.

-

The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from “de Finibus Bonorum et Malorum” by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham.

-

Prologue

-

Synopsis: Explanation from the lipsum.com website.

-

Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.

-

Act One

-

“Fusce maximus felis libero”

-

Chapter One: Chapter One

-

Point of View: Bod

-

Plot: Main

-

Locations: Europe

-

Synopsis: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam.

-

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam. Praesent magna nunc, lacinia sit amet quam eget, aliquet ultrices justo. Morbi ornare enim et lorem rutrum finibus ut eu dolor. Aliquam a orci odio. Ut ultrices sem quis massa placerat, eget mollis nisl cursus. Cras vel sagittis justo. Ut non ultricies leo. Maecenas rutrum velit in est varius, et egestas massa pulvinar.

-

Scene 1.1: Scene One

-

Point of View: Bod

-

Plot: Main

-

Locations: Europe

-

Synopsis: Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur.

-

Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur. Donec quis ante nunc. Mauris ut leo ipsum. Vestibulum est neque, hendrerit nec neque a, ullamcorper lobortis tellus. Fusce sollicitudin purus quis congue bibendum. Aliquam condimentum ipsum tristique blandit tristique. Donec pulvinar neque ac suscipit malesuada.

-

Aliquam ut nisl arcu. Ut ultricies, lorem dignissim rutrum convallis, risus orci tempus lectus, congue feugiat sem lectus vitae odio. Duis sit amet justo finibus, hendrerit nulla at, ullamcorper enim. Praesent vel tellus sit amet tellus vulputate bibendum. Morbi eleifend sagittis sem, ac volutpat ante congue non. In hac habitasse platea dictumst. Morbi lobortis fermentum elit, dignissim sagittis ligula volutpat lacinia. Vestibulum eu interdum odio. Integer ac purus commodo metus congue tempor non at urna. Sed eget tortor vel quam viverra egestas. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Donec non convallis mauris, ac feugiat ex.

-

1.1.1: Scene One, Section Two

-

Integer vel libero ipsum. Donec varius aliquam libero, sit amet commodo urna hendrerit non. Nullam quis erat mollis nunc viverra volutpat tincidunt in odio. Nam vitae quam sem. Aliquam suscipit nulla non lorem pharetra semper. Ut suscipit erat eu ligula accumsan ultrices. Phasellus nisl tellus, placerat sed laoreet id, consectetur nec dolor. Sed fringilla ipsum id dapibus posuere. Aenean finibus pharetra tincidunt. Ut molestie malesuada nulla, id posuere lorem tincidunt eu. Aliquam tempor eros a est vulputate, scelerisque pulvinar ipsum fermentum. In hac habitasse platea dictumst.

-

Curabitur congue, justo quis interdum fermentum, tellus nulla imperdiet sapien, eu interdum enim tellus condimentum metus. Vivamus nunc velit, dignissim ut ultrices sit amet, ultricies quis enim. Donec ut vestibulum neque. Vivamus semper neque id ex ullamcorper varius. Fusce mattis nibh viverra lorem sagittis, et tempor arcu congue. Suspendisse sit amet felis sed urna facilisis mattis eget vitae arcu. Proin eu magna hendrerit, tristique sem maximus, placerat diam. Nulla tristique sed velit sit amet varius. Etiam vel ornare magna, in vulputate arcu. Cras velit orci, tincidunt sed volutpat cursus, bibendum vel sem. Nunc vulputate pharetra tortor, ac consectetur neque tincidunt sit amet. Nulla ornare mi sed mi dignissim ultricies. Ut tincidunt bibendum mauris, sed elementum ex vulputate vel. Mauris fermentum, felis nec vehicula congue, felis lorem facilisis erat, a dictum dolor augue vitae quam. Maecenas rutrum tortor nec consequat eleifend.

-

Scene 1.2: Scene Two

-

Point of View: Bod

-

Plot: Main

-

Locations: Europe

-

Synopsis: Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci.

-

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci. Vestibulum facilisis bibendum aliquam. Aliquam posuere, turpis ac bibendum varius, sem tellus venenatis risus, in elementum massa enim ac lorem. Integer in sem ac diam blandit ultricies ut in nulla. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam sit amet erat est. Curabitur vitae cursus justo, sit amet placerat dolor. Vivamus eu felis hendrerit, tincidunt massa rutrum, maximus arcu. Pellentesque commodo justo odio, vel rutrum nulla tincidunt eu. Integer non neque condimentum, convallis diam non, varius ligula. Aliquam eget sapien mauris. Aenean pharetra nunc nisi, vel maximus ante tristique sit amet. Aliquam risus metus, interdum non odio eu, consectetur lacinia sapien.

-

Proin vitae gravida nisl. Integer viverra orci turpis, sit amet pretium ligula facilisis consequat. Nulla interdum commodo metus, mollis consequat dui tincidunt et. Proin consequat bibendum justo id commodo. Fusce fermentum nunc turpis, eu vestibulum risus feugiat ut. Sed scelerisque vel ligula ut interdum. Suspendisse ac blandit ligula, sagittis fringilla dolor. In tincidunt convallis diam et ornare. Aenean id dignissim est, ut rhoncus quam. Donec vitae nisl velit. In convallis nibh ut augue dignissim, eu elementum quam cursus. Phasellus in lectus lorem. Curabitur in pellentesque nisi, at gravida sapien. Sed cursus justo volutpat lacus placerat, sit amet dignissim turpis commodo. Aliquam vitae orci eget nulla posuere condimentum in ut felis.

-

Nulla accumsan ante in pulvinar efficitur. Nulla non velit quis urna hendrerit bibendum. Suspendisse ultrices ante eu justo malesuada, sed fermentum enim rutrum. Nunc fermentum pharetra felis, vitae sollicitudin quam rutrum porta. Aliquam fringilla velit a mi laoreet, et luctus est rutrum. In gravida non ipsum sit amet tempus. Curabitur et eleifend purus. Nulla facilisi.

-

1.2.1: Scene Two, Section Two

-

Suspendisse potenti. Fusce tempus lorem nec laoreet suscipit. Fusce vulputate nisl ac diam tincidunt, nec malesuada quam pellentesque. Maecenas congue, tellus quis commodo rutrum, magna leo egestas arcu, quis suscipit ex risus id ligula. Suspendisse potenti. Morbi blandit lacus vitae laoreet vulputate. Donec vitae tellus eleifend, lobortis eros eu, tincidunt enim. Nullam et ullamcorper nisi. Vivamus tellus ex, lobortis quis rutrum ut, dapibus sit amet turpis. Phasellus pellentesque metus diam, commodo tristique ante commodo ac. Ut mollis ipsum nec diam blandit sollicitudin. Duis bibendum lacus nec commodo dapibus. Sed condimentum luctus ante, id ultricies urna varius nec. Nam convallis magna nec bibendum ultrices. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed auctor pharetra quam, vitae porta ex bibendum eu.

-

Vivamus ut venenatis lectus. Phasellus nec elit id sem dictum ornare. Quisque feugiat, diam eget sagittis ultricies, orci turpis efficitur nisi, et fringilla justo odio nec nibh. In hac habitasse platea dictumst. Sed tempus bibendum feugiat. Etiam luctus mauris arcu, non interdum ipsum ultrices id. Vivamus blandit urna sit amet scelerisque vulputate. Quisque in metus eget massa rutrum dictum sit amet sed nulla. Vivamus vel efficitur dolor.

-

Ut et consequat enim, quis ornare nibh. In lectus neque, mollis et suscipit et, vestibulum vitae augue. Praesent id ante sit amet odio venenatis placerat a at erat. Sed sed metus sed nisi dictum varius. Integer tincidunt fermentum purus ac porta. Fusce porttitor non risus eget tristique. Donec augue nunc, maximus at fermentum vel, varius et neque. Ut sed consectetur mauris. Quisque ipsum enim, porttitor vitae imperdiet sit amet, tempor et mauris. Aliquam malesuada tincidunt lectus quis blandit. Sed commodo orci felis, quis ultrices tellus facilisis sed. Nunc vel varius est. Duis ullamcorper eu metus in pulvinar. Morbi at sapien dictum, rutrum mauris eget, interdum tellus.

-

Why do we use it?

-

Comment: Exctracted from the lipsum.com website.

-

It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.

-

The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, as opposed to using 'Content here, content here', making it look like readable English.

-

Many desktop publishing packages and web page editors now use Lorem Ipsum as their default model text, and a search for 'lorem ipsum' will uncover many web sites still in their infancy. Various versions have evolved over the years, sometimes by accident, sometimes on purpose (injected humour and the like).

-

Chapter Two: Chapter Two

-

Point of View: Bod

-

Plot: Main

-

Locations: Europe

-

Synopsis: Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue.

-

Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue. Vestibulum pharetra fermentum leo, sed faucibus eros placerat quis. In hac habitasse platea dictumst. Donec metus massa, rutrum quis consequat et, tincidunt ac felis. Duis mollis metus ac nunc tincidunt blandit. Ut aliquet velit eu odio pharetra condimentum. Integer rutrum lacus orci, id venenatis libero accumsan at.

-

Scene 2.1: Scene Three

-

Point of View: Bod

-

Plot: Main

-

Locations: Europe

-

Synopsis: Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.

-

Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Aenean tincidunt lacus vitae nibh elementum eleifend. Sed rutrum condimentum sem quis blandit. Duis imperdiet libero metus, quis convallis quam faucibus a. Nulla ligula est, semper quis sollicitudin et, pretium id justo. Curabitur pharetra risus eget consectetur commodo. Duis mattis arcu non est condimentum, id venenatis risus volutpat. Pellentesque aliquet mauris non mauris porttitor ultrices. Phasellus ut vestibulum mi. Suspendisse malesuada metus lorem, a malesuada orci rhoncus a. Praesent euismod convallis ante, lacinia tincidunt ex egestas id. Praesent sit amet efficitur sapien. Morbi tincidunt volutpat nunc sed dictum. Aliquam ultrices metus id fermentum lobortis.

-

Pellentesque id sagittis dui. Praesent ut nisi sit amet libero euismod ornare. Vestibulum vehicula, lorem eget aliquet imperdiet, eros nulla iaculis mi, vel bibendum est dui sed orci. Nullam vitae lorem rutrum, euismod lacus id, ullamcorper lectus. Duis nec commodo mi, a fringilla diam. Vestibulum molestie nibh tristique, viverra augue non, aliquet metus. Phasellus a tellus ac nisl tempor aliquet. Nulla vitae sapien rutrum augue ornare ultrices a quis nisi. Sed pulvinar tincidunt ex. Fusce vel sem vitae ante pellentesque lobortis.

-

Maecenas ullamcorper lacus nec turpis finibus aliquet eget rutrum augue. Integer lorem erat, faucibus non lacus lacinia, pulvinar egestas felis. Proin rutrum nunc eget nulla varius, id blandit mauris tincidunt. Donec sit amet ullamcorper nisi, ut efficitur mi. Aliquam aliquet, nulla eget rhoncus tristique, justo lorem consectetur dui, id ornare leo odio sed tellus. Curabitur interdum velit a turpis condimentum venenatis. Nunc rhoncus sem ac augue auctor, nec malesuada ex fringilla. Vestibulum egestas diam sed leo consectetur vulputate quis eget enim. Nam tincidunt metus sit amet maximus ullamcorper. Sed placerat velit vitae massa efficitur viverra. Etiam eleifend dignissim ante, sed luctus nisl tristique a. In vestibulum pharetra dolor in molestie. Vivamus auctor massa ac magna imperdiet, sit amet iaculis turpis finibus.

-

Aenean dapibus vulputate purus, sit amet tempor nunc suscipit consequat. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Mauris auctor congue eros, non pellentesque neque dapibus ac. Vestibulum non leo nec urna lacinia eleifend quis et diam. Praesent eu nisi magna. Nulla at magna massa. Suspendisse porta varius scelerisque. Duis at auctor dolor, non dapibus urna. Nunc venenatis feugiat magna non molestie. Aliquam non ornare ex. Quisque eu ultrices velit, quis pellentesque eros. Phasellus eleifend, elit id imperdiet aliquam, nulla quam molestie turpis, at egestas odio ante et tortor. Suspendisse fringilla condimentum justo, at aliquet odio aliquam ac.

-

Scene 2.2: Scene Four

-

Point of View: Bod

-

Plot: Main

-

Locations: Europe

-

Synopsis: Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo.

-

Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo. Nullam viverra dui et auctor pretium. Ut ullamcorper velit urna, sed imperdiet massa convallis a. Suspendisse efficitur, ipsum nec cursus pulvinar, eros urna posuere diam, nec elementum mi felis vitae sapien.

-

Duis efficitur metus pulvinar, molestie magna eget, feugiat dui. Fusce convallis vehicula ipsum convallis blandit. Duis eros risus, malesuada eu imperdiet in, hendrerit ac metus. Vestibulum id justo gravida, dignissim nibh non, iaculis diam. Fusce accumsan est ut massa porta ultricies. Nulla vitae justo in tortor laoreet mollis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin eu libero justo. Vivamus aliquet placerat est, et auctor eros posuere venenatis. Nunc quam diam, tincidunt ac aliquet in, fermentum sit amet lectus. Proin commodo tincidunt blandit. Quisque erat arcu, semper nec dui non, consectetur gravida ipsum. Nullam pretium consectetur elit at condimentum.

-

Etiam sagittis, erat vitae accumsan tempor, neque augue scelerisque nulla, ut ultrices justo urna sit amet augue. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean at pulvinar tortor. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Cras vel porta quam. Nullam eu mauris mollis, vehicula justo vel, placerat sapien. Phasellus viverra elit et vestibulum pharetra. Vestibulum commodo fermentum leo, eu porta nisi aliquam eget. Nulla tempus porttitor nisi nec mollis. Nam non mollis turpis. Nam finibus leo a bibendum tincidunt. Donec commodo velit magna, ac semper sapien mattis id. Proin sem velit, lobortis quis ultricies id, pharetra et lectus. Vestibulum condimentum neque vitae mi dapibus mollis. Mauris luctus vel sapien vitae hendrerit.

-

Aenean vestibulum magna placerat fermentum tempus. Nam auctor condimentum nunc, in elementum quam ornare a. Etiam in ipsum elit. Proin pharetra, dolor sollicitudin pellentesque congue, lorem dolor ultricies magna, non iaculis risus nisl dictum diam. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Vivamus vel euismod nibh, et lobortis dolor. Maecenas dui odio, gravida nec molestie ut, feugiat ut arcu. Pellentesque risus sapien, gravida a convallis quis, ullamcorper porttitor sapien.

-

Donec ipsum eros, vestibulum sit amet cursus eget, iaculis quis dolor. Pellentesque magna augue, tristique dapibus mi vitae, molestie venenatis enim. Nam malesuada, turpis volutpat rhoncus ullamcorper, justo est eleifend orci, ut luctus risus ex rutrum arcu. Sed mi elit, feugiat rhoncus ornare sed, porta id leo. Pellentesque feugiat nulla tincidunt erat suscipit, eu congue lacus hendrerit. Morbi pulvinar enim sed consequat auctor. Ut eleifend enim sem, vitae euismod ex ultricies sit amet. Curabitur eu efficitur nisi, suscipit finibus sapien. In sodales blandit erat, vestibulum pulvinar ante volutpat nec. Vivamus dictum non libero at molestie. Donec sit amet neque in ante convallis pretium. Nunc vel iaculis dui.

-

Phasellus eu nunc ut nunc faucibus laoreet. Aliquam at magna risus. Praesent lobortis, risus finibus semper varius, magna purus vestibulum eros, at pulvinar sapien enim a ex. In scelerisque malesuada ex, sit amet egestas neque condimentum sed. Praesent vulputate efficitur massa. Cras at accumsan ligula. In elementum lectus eget blandit dictum. Nam vitae libero ut justo eleifend rutrum ac nec arcu. Aliquam sodales in quam congue vestibulum. Aliquam in accumsan sapien. Quisque lobortis nisl nisi, vitae bibendum turpis efficitur sed. Vestibulum tempor nulla eget nisi convallis, blandit sagittis ipsum convallis. Donec odio nibh, ultrices quis odio in, mollis euismod libero.

-

Scene 2.3: Scene Five

-

Point of View: Bod

-

Plot: Main

-

Locations: Europe

-

Synopsis: Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.

-

Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. In sed felis auctor, rhoncus dui ac, consequat dolor. Integer volutpat libero sed nisl aliquet varius. Suspendisse et lorem sapien. Proin id ultrices nibh, ac suscipit diam. Suspendisse placerat varius porttitor. Curabitur elementum sed enim ultrices imperdiet.

-

In ut lobortis lacus, nec luctus arcu. Vivamus condimentum sapien a ipsum malesuada sodales. Donec et vestibulum risus. Integer dictum euismod eros id tincidunt. Aliquam sagittis leo vitae consequat fermentum. Donec maximus ex eu ex iaculis porta. Praesent pharetra lacinia risus, et eleifend diam commodo non. Sed feugiat ipsum ut orci sagittis, quis faucibus lectus blandit. Sed tellus quam, gravida vitae laoreet quis, tempus lobortis dui. Vivamus semper accumsan ullamcorper. Praesent tempus pretium eros, non elementum risus. Pellentesque odio quam, auctor quis ex non, vulputate egestas dolor. Nunc luctus enim ut justo sodales consectetur. Sed aliquet a mauris vel posuere.

-

Donec luctus lectus efficitur, blandit nisi vitae, dignissim tellus. Pellentesque euismod pharetra augue gravida hendrerit. Quisque nisi mi, mattis ac nisi non, maximus malesuada ante. Nulla lobortis, diam eu ornare ornare, tellus enim feugiat arcu, non vestibulum tortor nunc eu justo. Integer blandit felis justo, eu semper est scelerisque vel. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nam ultricies, nisi vel elementum commodo, nisl dolor tincidunt magna, sed varius est nunc at lectus. Aliquam dolor tortor, sodales placerat ultricies quis, sodales quis sapien. Duis ullamcorper sollicitudin risus at mattis. Integer consequat et nunc at condimentum. Pellentesque cursus congue augue, non suscipit lectus sodales ut. Nam a mi bibendum, blandit nisl eu, accumsan nunc. Aliquam a ex mauris. Sed nec sem quis arcu dignissim tempus eget et turpis. Ut sed ex nec ipsum ultrices lobortis.

-

Pellentesque rhoncus pharetra eros, non mollis nisi pretium non. Mauris accumsan quis odio quis euismod. Maecenas ultrices, augue et aliquam tincidunt, erat tellus ornare ligula, quis ultrices turpis nibh vel justo. Fusce gravida odio tellus. In a congue diam. Mauris consequat ex id leo lacinia dictum. Fusce id sem sodales, ultrices sapien ac, convallis orci. Donec gravida nunc sit amet nisi hendrerit, sed porta enim aliquam. In hac habitasse platea dictumst. Cras a orci felis. Curabitur non felis nec urna maximus auctor ut ut nisi. Curabitur at turpis eleifend, blandit eros at, molestie odio. Phasellus euismod neque augue.

-

Integer egestas maximus leo eu facilisis. Nunc rhoncus dignissim lectus eu lacinia. Praesent lacinia urna porttitor aliquam condimentum. Nulla eu eros dictum, dictum nunc vitae, sagittis nibh. Integer ante neque, consequat nec sollicitudin id, consectetur vitae dolor. Nullam volutpat sem orci, quis viverra magna auctor a. Suspendisse potenti. Maecenas commodo sed neque pellentesque vehicula. Sed luctus nisl risus, elementum semper purus interdum vel. Ut pulvinar, massa sit amet venenatis placerat, nunc lacus hendrerit odio, non aliquet nunc risus eu lectus. Maecenas feugiat semper ligula, id lobortis sem porta eu. Integer posuere elit magna, at mollis eros bibendum et. Ut imperdiet purus vel nulla aliquam maximus. Morbi sodales purus tellus, a rhoncus sem rutrum sit amet. Quisque risus sem, laoreet nec convallis nec, rutrum vitae justo.

-

Notes: Characters

-

Nobody Owens

-

Tag: Bod

-

Plot: Main

-

Pellentesque nec erat ut nulla posuere commodo. Curabitur nisi augue, imperdiet et porta imperdiet, efficitur id leo. Cras finibus arcu at nibh commodo congue. Proin suscipit placerat condimentum. Aenean ante enim, cursus id lorem a, blandit venenatis nibh. Maecenas suscipit porta elit, sit amet porta felis porttitor eu. Sed a dui nibh. Phasellus sed faucibus dui. Pellentesque felis nulla, ultrices non efficitur quis, rutrum id mi. Mauris tempus auctor nisl, in bibendum enim pellentesque sit amet. Proin nunc lacus, imperdiet nec posuere ac, interdum non lectus.

-

Suspendisse faucibus est auctor orci mollis luctus. Praesent quis sodales neque. Interdum et malesuada fames ac ante ipsum primis in faucibus. Donec sodales rutrum mattis. In in sem ornare, consequat nulla ac, convallis arcu. Duis ac metus id felis commodo commodo sit amet eget diam. Curabitur rhoncus lacinia leo at sodales. Etiam finibus porta diam a viverra. Praesent nisi urna, volutpat sit amet odio at, vehicula vehicula leo. In non enim eget nisl luctus commodo. Pellentesque pellentesque at lectus at luctus. Quisque nec felis bibendum, lacinia libero ut, lacinia eros. Integer finibus ultricies nibh sit amet placerat.

-

Nullam scelerisque velit et tortor congue vestibulum a at nisi. Vivamus sodales ut turpis a convallis. In dignissim nibh at luctus sodales. Etiam sit amet rhoncus massa. Phasellus ligula magna, sollicitudin non imperdiet sit amet, volutpat vel magna. Nunc vestibulum tempor lectus, sit amet porta nunc hendrerit in. Curabitur non odio sit amet massa tincidunt facilisis. Integer et luctus nunc, eget euismod leo. Praesent faucibus metus sed purus convallis scelerisque. Fusce viverra lorem et placerat malesuada. In at elit malesuada, ullamcorper risus vitae, sodales dolor. Donec quis elementum lectus. Quisque eu eros at dui imperdiet euismod ut id neque.

-

Notes: Plot

-

Main Plot

-

Tag: Main

-

Suspendisse vulputate malesuada pellentesque. Aenean sollicitudin cursus mi, vitae ultricies felis ullamcorper eu. Duis luctus risus mi, in accumsan velit cursus ut. Vestibulum eleifend leo in magna eleifend fermentum. Proin nec ornare elit. Phasellus nec interdum risus. In a volutpat augue, quis egestas justo. Morbi porta mauris mattis bibendum imperdiet.

-

Mauris ut erat eu lorem malesuada egestas vel vel urna. Maecenas ac semper quam. Maecenas aliquet metus non interdum mattis. Proin consectetur molestie ligula. Aliquam sollicitudin pulvinar urna a pellentesque. Suspendisse ultrices, est mattis scelerisque porta, nisi nisi laoreet nisl, non condimentum quam ante a velit. Proin scelerisque justo augue, nec laoreet ligula egestas at. Etiam enim quam, ultrices non accumsan hendrerit, elementum vel ligula. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Nam efficitur odio libero, in vestibulum arcu aliquam at. Cras non vehicula augue. Integer lobortis, est vitae aliquam facilisis, metus ligula aliquet eros, at porttitor sem tortor eget massa. Aliquam varius scelerisque neque sed gravida. Aenean eleifend lorem id ante elementum sollicitudin. Proin commodo massa a quam volutpat, mollis fermentum turpis efficitur.

-

Notes: World

-

Ancient Europe

-

Tag: Europe

-

Vivamus sodales risus ac accumsan posuere. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nunc vel enim felis. Vestibulum dignissim massa nunc, a auctor magna eleifend et. Proin dignissim sodales erat vitae convallis. Aliquam id tellus dui. Curabitur sollicitudin scelerisque ex sit amet posuere. Nam rutrum felis id rhoncus feugiat. Duis sagittis quam quis purus efficitur, quis rutrum odio iaculis. Maecenas semper ante turpis, at vulputate mi consectetur non. Sed rutrum nibh turpis, quis rhoncus purus ornare quis. Vestibulum at rutrum mauris. Integer dolor nisi, tincidunt eget vehicula ac, ultricies at ligula.

-

Aenean semper turpis quis varius rhoncus. Vivamus ac mi eget felis euismod vulputate. Nam eu tempus velit. Etiam ut est porta, finibus erat sit amet, consectetur felis. Nullam consequat felis ut lacus pharetra, in lobortis urna mollis. Nulla varius eros nec lorem rhoncus, sed venenatis risus ultrices. Phasellus pellentesque laoreet neque, ut ultricies lacus vulputate quis. In malesuada dui sit amet est interdum, eget consectetur mi gravida. Cras vel bibendum purus. Quisque commodo tempor arcu, non lacinia sem blandit eleifend. Quisque at neque gravida, porttitor metus a, suscipit diam. Quisque convallis sodales lacus et condimentum. Donec a suscipit diam. Pellentesque eget cursus neque.

-

Nunc ullamcorper magna quis elit condimentum rhoncus. Aenean dictum pulvinar dolor suscipit interdum. Aliquam elit massa, elementum nec cursus eu, maximus nec ipsum. Donec ullamcorper iaculis dolor eu commodo. Nunc eget tortor quis turpis consectetur varius. Vestibulum nec justo vel tellus venenatis condimentum. Duis auctor iaculis massa. Nunc risus magna, rutrum vitae eros non, tristique mollis enim.

-
- - diff --git a/tests/reference/guiBuild_Tool_Step2_Lorem_Ipsum.md b/tests/reference/guiBuild_Tool_Step2_Lorem_Ipsum.md deleted file mode 100644 index 37311525..00000000 --- a/tests/reference/guiBuild_Tool_Step2_Lorem_Ipsum.md +++ /dev/null @@ -1,183 +0,0 @@ -# Lorem Ipsum - -**By lipsum.com** - -“Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit…” - -“There is no one who loves pain itself, who seeks after it and wants to have it, simply because it is pain…” - -**Comment:** Exctracted from the lipsum.com website. - -Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of “de Finibus Bonorum et Malorum” (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, “Lorem ipsum dolor sit amet..”, comes from a line in section 1.10.32. - -The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from “de Finibus Bonorum et Malorum” by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham. - -## Prologue - -**Synopsis:** Explanation from the lipsum.com website. - -_Lorem Ipsum_ is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. - -# Act One - -“Fusce maximus felis libero” - -## Chapter One: Chapter One - -**Point of View:** Bod -**Plot:** Main -**Locations:** Europe - -**Synopsis:** Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam. - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam. Praesent magna nunc, lacinia sit amet quam eget, aliquet ultrices justo. Morbi ornare enim et lorem rutrum finibus ut eu dolor. Aliquam a orci odio. Ut ultrices sem quis massa placerat, eget mollis nisl cursus. Cras vel sagittis justo. Ut non ultricies leo. Maecenas rutrum velit in est varius, et egestas massa pulvinar. - -### Scene 1.1: Scene One - -**Point of View:** Bod -**Plot:** Main -**Locations:** Europe - -**Synopsis:** Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur. - -Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur. Donec quis ante nunc. Mauris ut leo ipsum. Vestibulum est neque, hendrerit nec neque a, ullamcorper lobortis tellus. Fusce sollicitudin purus quis congue bibendum. Aliquam condimentum ipsum tristique blandit tristique. Donec pulvinar neque ac suscipit malesuada. - -Aliquam ut nisl arcu. Ut ultricies, lorem dignissim rutrum convallis, risus orci tempus lectus, congue feugiat sem lectus vitae odio. Duis sit amet justo finibus, hendrerit nulla at, ullamcorper enim. Praesent vel tellus sit amet tellus vulputate bibendum. Morbi eleifend sagittis sem, ac volutpat ante congue non. In hac habitasse platea dictumst. Morbi lobortis fermentum elit, dignissim sagittis ligula volutpat lacinia. Vestibulum eu interdum odio. Integer ac purus commodo metus congue tempor non at urna. Sed eget tortor vel quam viverra egestas. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Donec non convallis mauris, ac feugiat ex. - -#### 1.1.1: Scene One, Section Two - -Integer vel libero ipsum. Donec varius aliquam libero, sit amet commodo urna hendrerit non. Nullam quis erat mollis nunc viverra volutpat tincidunt in odio. Nam vitae quam sem. Aliquam suscipit nulla non lorem pharetra semper. Ut suscipit erat eu ligula accumsan ultrices. Phasellus nisl tellus, placerat sed laoreet id, consectetur nec dolor. Sed fringilla ipsum id dapibus posuere. Aenean finibus pharetra tincidunt. Ut molestie malesuada nulla, id posuere lorem tincidunt eu. Aliquam tempor eros a est vulputate, scelerisque pulvinar ipsum fermentum. In hac habitasse platea dictumst. - -Curabitur congue, justo quis interdum fermentum, tellus nulla imperdiet sapien, eu interdum enim tellus condimentum metus. Vivamus nunc velit, dignissim ut ultrices sit amet, ultricies quis enim. Donec ut vestibulum neque. Vivamus semper neque id ex ullamcorper varius. Fusce mattis nibh viverra lorem sagittis, et tempor arcu congue. Suspendisse sit amet felis sed urna facilisis mattis eget vitae arcu. Proin eu magna hendrerit, tristique sem maximus, placerat diam. Nulla tristique sed velit sit amet varius. Etiam vel ornare magna, in vulputate arcu. Cras velit orci, tincidunt sed volutpat cursus, bibendum vel sem. Nunc vulputate pharetra tortor, ac consectetur neque tincidunt sit amet. Nulla ornare mi sed mi dignissim ultricies. Ut tincidunt bibendum mauris, sed elementum ex vulputate vel. Mauris fermentum, felis nec vehicula congue, felis lorem facilisis erat, a dictum dolor augue vitae quam. Maecenas rutrum tortor nec consequat eleifend. - -### Scene 1.2: Scene Two - -**Point of View:** Bod -**Plot:** Main -**Locations:** Europe - -**Synopsis:** Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci. - -Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci. Vestibulum facilisis bibendum aliquam. Aliquam posuere, turpis ac bibendum varius, sem tellus venenatis risus, in elementum massa enim ac lorem. Integer in sem ac diam blandit ultricies ut in nulla. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam sit amet erat est. Curabitur vitae cursus justo, sit amet placerat dolor. Vivamus eu felis hendrerit, tincidunt massa rutrum, maximus arcu. Pellentesque commodo justo odio, vel rutrum nulla tincidunt eu. Integer non neque condimentum, convallis diam non, varius ligula. Aliquam eget sapien mauris. Aenean pharetra nunc nisi, vel maximus ante tristique sit amet. Aliquam risus metus, interdum non odio eu, consectetur lacinia sapien. - -Proin vitae gravida nisl. Integer viverra orci turpis, sit amet pretium ligula facilisis consequat. Nulla interdum commodo metus, mollis consequat dui tincidunt et. Proin consequat bibendum justo id commodo. Fusce fermentum nunc turpis, eu vestibulum risus feugiat ut. Sed scelerisque vel ligula ut interdum. Suspendisse ac blandit ligula, sagittis fringilla dolor. In tincidunt convallis diam et ornare. Aenean id dignissim est, ut rhoncus quam. Donec vitae nisl velit. In convallis nibh ut augue dignissim, eu elementum quam cursus. Phasellus in lectus lorem. Curabitur in pellentesque nisi, at gravida sapien. Sed cursus justo volutpat lacus placerat, sit amet dignissim turpis commodo. Aliquam vitae orci eget nulla posuere condimentum in ut felis. - -Nulla accumsan ante in pulvinar efficitur. Nulla non velit quis urna hendrerit bibendum. Suspendisse ultrices ante eu justo malesuada, sed fermentum enim rutrum. Nunc fermentum pharetra felis, vitae sollicitudin quam rutrum porta. Aliquam fringilla velit a mi laoreet, et luctus est rutrum. In gravida non ipsum sit amet tempus. Curabitur et eleifend purus. Nulla facilisi. - -#### 1.2.1: Scene Two, Section Two - -Suspendisse potenti. Fusce tempus lorem nec laoreet suscipit. Fusce vulputate nisl ac diam tincidunt, nec malesuada quam pellentesque. Maecenas congue, tellus quis commodo rutrum, magna leo egestas arcu, quis suscipit ex risus id ligula. Suspendisse potenti. Morbi blandit lacus vitae laoreet vulputate. Donec vitae tellus eleifend, lobortis eros eu, tincidunt enim. Nullam et ullamcorper nisi. Vivamus tellus ex, lobortis quis rutrum ut, dapibus sit amet turpis. Phasellus pellentesque metus diam, commodo tristique ante commodo ac. Ut mollis ipsum nec diam blandit sollicitudin. Duis bibendum lacus nec commodo dapibus. Sed condimentum luctus ante, id ultricies urna varius nec. Nam convallis magna nec bibendum ultrices. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed auctor pharetra quam, vitae porta ex bibendum eu. - -Vivamus ut venenatis lectus. Phasellus nec elit id sem dictum ornare. Quisque feugiat, diam eget sagittis ultricies, orci turpis efficitur nisi, et fringilla justo odio nec nibh. In hac habitasse platea dictumst. Sed tempus bibendum feugiat. Etiam luctus mauris arcu, non interdum ipsum ultrices id. Vivamus blandit urna sit amet scelerisque vulputate. Quisque in metus eget massa rutrum dictum sit amet sed nulla. Vivamus vel efficitur dolor. - -Ut et consequat enim, quis ornare nibh. In lectus neque, mollis et suscipit et, vestibulum vitae augue. Praesent id ante sit amet odio venenatis placerat a at erat. Sed sed metus sed nisi dictum varius. Integer tincidunt fermentum purus ac porta. Fusce porttitor non risus eget tristique. Donec augue nunc, maximus at fermentum vel, varius et neque. Ut sed consectetur mauris. Quisque ipsum enim, porttitor vitae imperdiet sit amet, tempor et mauris. Aliquam malesuada tincidunt lectus quis blandit. Sed commodo orci felis, quis ultrices tellus facilisis sed. Nunc vel varius est. Duis ullamcorper eu metus in pulvinar. Morbi at sapien dictum, rutrum mauris eget, interdum tellus. - -## Why do we use it? - -**Comment:** Exctracted from the lipsum.com website. - - It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout. - - The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, as opposed to using 'Content here, content here', making it look like readable English. - - Many desktop publishing packages and web page editors now use Lorem Ipsum as their default model text, and a search for 'lorem ipsum' will uncover many web sites still in their infancy. Various versions have evolved over the years, sometimes by accident, sometimes on purpose (injected humour and the like). - -## Chapter Two: Chapter Two - -**Point of View:** Bod -**Plot:** Main -**Locations:** Europe - -**Synopsis:** Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue. - -Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue. Vestibulum pharetra fermentum leo, sed faucibus eros placerat quis. In hac habitasse platea dictumst. Donec metus massa, rutrum quis consequat et, tincidunt ac felis. Duis mollis metus ac nunc tincidunt blandit. Ut aliquet velit eu odio pharetra condimentum. Integer rutrum lacus orci, id venenatis libero accumsan at. - -### Scene 2.1: Scene Three - -**Point of View:** Bod -**Plot:** Main -**Locations:** Europe - -**Synopsis:** Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. - -Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Aenean tincidunt lacus vitae nibh elementum eleifend. Sed rutrum condimentum sem quis blandit. Duis imperdiet libero metus, quis convallis quam faucibus a. Nulla ligula est, semper quis sollicitudin et, pretium id justo. Curabitur pharetra risus eget consectetur commodo. Duis mattis arcu non est condimentum, id venenatis risus volutpat. Pellentesque aliquet mauris non mauris porttitor ultrices. Phasellus ut vestibulum mi. Suspendisse malesuada metus lorem, a malesuada orci rhoncus a. Praesent euismod convallis ante, lacinia tincidunt ex egestas id. Praesent sit amet efficitur sapien. Morbi tincidunt volutpat nunc sed dictum. Aliquam ultrices metus id fermentum lobortis. - -Pellentesque id sagittis dui. Praesent ut nisi sit amet libero euismod ornare. Vestibulum vehicula, lorem eget aliquet imperdiet, eros nulla iaculis mi, vel bibendum est dui sed orci. Nullam vitae lorem rutrum, euismod lacus id, ullamcorper lectus. Duis nec commodo mi, a fringilla diam. Vestibulum molestie nibh tristique, viverra augue non, aliquet metus. Phasellus a tellus ac nisl tempor aliquet. Nulla vitae sapien rutrum augue ornare ultrices a quis nisi. Sed pulvinar tincidunt ex. Fusce vel sem vitae ante pellentesque lobortis. - -Maecenas ullamcorper lacus nec turpis finibus aliquet eget rutrum augue. Integer lorem erat, faucibus non lacus lacinia, pulvinar egestas felis. Proin rutrum nunc eget nulla varius, id blandit mauris tincidunt. Donec sit amet ullamcorper nisi, ut efficitur mi. Aliquam aliquet, nulla eget rhoncus tristique, justo lorem consectetur dui, id ornare leo odio sed tellus. Curabitur interdum velit a turpis condimentum venenatis. Nunc rhoncus sem ac augue auctor, nec malesuada ex fringilla. Vestibulum egestas diam sed leo consectetur vulputate quis eget enim. Nam tincidunt metus sit amet maximus ullamcorper. Sed placerat velit vitae massa efficitur viverra. Etiam eleifend dignissim ante, sed luctus nisl tristique a. In vestibulum pharetra dolor in molestie. Vivamus auctor massa ac magna imperdiet, sit amet iaculis turpis finibus. - -Aenean dapibus vulputate purus, sit amet tempor nunc suscipit consequat. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Mauris auctor congue eros, non pellentesque neque dapibus ac. Vestibulum non leo nec urna lacinia eleifend quis et diam. Praesent eu nisi magna. Nulla at magna massa. Suspendisse porta varius scelerisque. Duis at auctor dolor, non dapibus urna. Nunc venenatis feugiat magna non molestie. Aliquam non ornare ex. Quisque eu ultrices velit, quis pellentesque eros. Phasellus eleifend, elit id imperdiet aliquam, nulla quam molestie turpis, at egestas odio ante et tortor. Suspendisse fringilla condimentum justo, at aliquet odio aliquam ac. - -### Scene 2.2: Scene Four - -**Point of View:** Bod -**Plot:** Main -**Locations:** Europe - -**Synopsis:** Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo. - -Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo. Nullam viverra dui et auctor pretium. Ut ullamcorper velit urna, sed imperdiet massa convallis a. Suspendisse efficitur, ipsum nec cursus pulvinar, eros urna posuere diam, nec elementum mi felis vitae sapien. - -Duis efficitur metus pulvinar, molestie magna eget, feugiat dui. Fusce convallis vehicula ipsum convallis blandit. Duis eros risus, malesuada eu imperdiet in, hendrerit ac metus. Vestibulum id justo gravida, dignissim nibh non, iaculis diam. Fusce accumsan est ut massa porta ultricies. Nulla vitae justo in tortor laoreet mollis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin eu libero justo. Vivamus aliquet placerat est, et auctor eros posuere venenatis. Nunc quam diam, tincidunt ac aliquet in, fermentum sit amet lectus. Proin commodo tincidunt blandit. Quisque erat arcu, semper nec dui non, consectetur gravida ipsum. Nullam pretium consectetur elit at condimentum. - -Etiam sagittis, erat vitae accumsan tempor, neque augue scelerisque nulla, ut ultrices justo urna sit amet augue. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean at pulvinar tortor. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Cras vel porta quam. Nullam eu mauris mollis, vehicula justo vel, placerat sapien. Phasellus viverra elit et vestibulum pharetra. Vestibulum commodo fermentum leo, eu porta nisi aliquam eget. Nulla tempus porttitor nisi nec mollis. Nam non mollis turpis. Nam finibus leo a bibendum tincidunt. Donec commodo velit magna, ac semper sapien mattis id. Proin sem velit, lobortis quis ultricies id, pharetra et lectus. Vestibulum condimentum neque vitae mi dapibus mollis. Mauris luctus vel sapien vitae hendrerit. - -Aenean vestibulum magna placerat fermentum tempus. Nam auctor condimentum nunc, in elementum quam ornare a. Etiam in ipsum elit. Proin pharetra, dolor sollicitudin pellentesque congue, lorem dolor ultricies magna, non iaculis risus nisl dictum diam. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Vivamus vel euismod nibh, et lobortis dolor. Maecenas dui odio, gravida nec molestie ut, feugiat ut arcu. Pellentesque risus sapien, gravida a convallis quis, ullamcorper porttitor sapien. - -Donec ipsum eros, vestibulum sit amet cursus eget, iaculis quis dolor. Pellentesque magna augue, tristique dapibus mi vitae, molestie venenatis enim. Nam malesuada, turpis volutpat rhoncus ullamcorper, justo est eleifend orci, ut luctus risus ex rutrum arcu. Sed mi elit, feugiat rhoncus ornare sed, porta id leo. Pellentesque feugiat nulla tincidunt erat suscipit, eu congue lacus hendrerit. Morbi pulvinar enim sed consequat auctor. Ut eleifend enim sem, vitae euismod ex ultricies sit amet. Curabitur eu efficitur nisi, suscipit finibus sapien. In sodales blandit erat, vestibulum pulvinar ante volutpat nec. Vivamus dictum non libero at molestie. Donec sit amet neque in ante convallis pretium. Nunc vel iaculis dui. - -Phasellus eu nunc ut nunc faucibus laoreet. Aliquam at magna risus. Praesent lobortis, risus finibus semper varius, magna purus vestibulum eros, at pulvinar sapien enim a ex. In scelerisque malesuada ex, sit amet egestas neque condimentum sed. Praesent vulputate efficitur massa. Cras at accumsan ligula. In elementum lectus eget blandit dictum. Nam vitae libero ut justo eleifend rutrum ac nec arcu. Aliquam sodales in quam congue vestibulum. Aliquam in accumsan sapien. Quisque lobortis nisl nisi, vitae bibendum turpis efficitur sed. Vestibulum tempor nulla eget nisi convallis, blandit sagittis ipsum convallis. Donec odio nibh, ultrices quis odio in, mollis euismod libero. - -### Scene 2.3: Scene Five - -**Point of View:** Bod -**Plot:** Main -**Locations:** Europe - -**Synopsis:** Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. - -Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. In sed felis auctor, rhoncus dui ac, consequat dolor. Integer volutpat libero sed nisl aliquet varius. Suspendisse et lorem sapien. Proin id ultrices nibh, ac suscipit diam. Suspendisse placerat varius porttitor. Curabitur elementum sed enim ultrices imperdiet. - -In ut lobortis lacus, nec luctus arcu. Vivamus condimentum sapien a ipsum malesuada sodales. Donec et vestibulum risus. Integer dictum euismod eros id tincidunt. Aliquam sagittis leo vitae consequat fermentum. Donec maximus ex eu ex iaculis porta. Praesent pharetra lacinia risus, et eleifend diam commodo non. Sed feugiat ipsum ut orci sagittis, quis faucibus lectus blandit. Sed tellus quam, gravida vitae laoreet quis, tempus lobortis dui. Vivamus semper accumsan ullamcorper. Praesent tempus pretium eros, non elementum risus. Pellentesque odio quam, auctor quis ex non, vulputate egestas dolor. Nunc luctus enim ut justo sodales consectetur. Sed aliquet a mauris vel posuere. - -Donec luctus lectus efficitur, blandit nisi vitae, dignissim tellus. Pellentesque euismod pharetra augue gravida hendrerit. Quisque nisi mi, mattis ac nisi non, maximus malesuada ante. Nulla lobortis, diam eu ornare ornare, tellus enim feugiat arcu, non vestibulum tortor nunc eu justo. Integer blandit felis justo, eu semper est scelerisque vel. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nam ultricies, nisi vel elementum commodo, nisl dolor tincidunt magna, sed varius est nunc at lectus. Aliquam dolor tortor, sodales placerat ultricies quis, sodales quis sapien. Duis ullamcorper sollicitudin risus at mattis. Integer consequat et nunc at condimentum. Pellentesque cursus congue augue, non suscipit lectus sodales ut. Nam a mi bibendum, blandit nisl eu, accumsan nunc. Aliquam a ex mauris. Sed nec sem quis arcu dignissim tempus eget et turpis. Ut sed ex nec ipsum ultrices lobortis. - -Pellentesque rhoncus pharetra eros, non mollis nisi pretium non. Mauris accumsan quis odio quis euismod. Maecenas ultrices, augue et aliquam tincidunt, erat tellus ornare ligula, quis ultrices turpis nibh vel justo. Fusce gravida odio tellus. In a congue diam. Mauris consequat ex id leo lacinia dictum. Fusce id sem sodales, ultrices sapien ac, convallis orci. Donec gravida nunc sit amet nisi hendrerit, sed porta enim aliquam. In hac habitasse platea dictumst. Cras a orci felis. Curabitur non felis nec urna maximus auctor ut ut nisi. Curabitur at turpis eleifend, blandit eros at, molestie odio. Phasellus euismod neque augue. - -Integer egestas maximus leo eu facilisis. Nunc rhoncus dignissim lectus eu lacinia. Praesent lacinia urna porttitor aliquam condimentum. Nulla eu eros dictum, dictum nunc vitae, sagittis nibh. Integer ante neque, consequat nec sollicitudin id, consectetur vitae dolor. Nullam volutpat sem orci, quis viverra magna auctor a. Suspendisse potenti. Maecenas commodo sed neque pellentesque vehicula. Sed luctus nisl risus, elementum semper purus interdum vel. Ut pulvinar, massa sit amet venenatis placerat, nunc lacus hendrerit odio, non aliquet nunc risus eu lectus. Maecenas feugiat semper ligula, id lobortis sem porta eu. Integer posuere elit magna, at mollis eros bibendum et. Ut imperdiet purus vel nulla aliquam maximus. Morbi sodales purus tellus, a rhoncus sem rutrum sit amet. Quisque risus sem, laoreet nec convallis nec, rutrum vitae justo. - -# Notes: Characters - -# Nobody Owens - -**Tag:** Bod -**Plot:** Main - -Pellentesque nec erat ut nulla posuere commodo. Curabitur nisi augue, imperdiet et porta imperdiet, efficitur id leo. Cras finibus arcu at nibh commodo congue. Proin suscipit placerat condimentum. Aenean ante enim, cursus id lorem a, blandit venenatis nibh. Maecenas suscipit porta elit, sit amet porta felis porttitor eu. Sed a dui nibh. Phasellus sed faucibus dui. Pellentesque felis nulla, ultrices non efficitur quis, rutrum id mi. Mauris tempus auctor nisl, in bibendum enim pellentesque sit amet. Proin nunc lacus, imperdiet nec posuere ac, interdum non lectus. - -Suspendisse faucibus est auctor orci mollis luctus. Praesent quis sodales neque. Interdum et malesuada fames ac ante ipsum primis in faucibus. Donec sodales rutrum mattis. In in sem ornare, consequat nulla ac, convallis arcu. Duis ac metus id felis commodo commodo sit amet eget diam. Curabitur rhoncus lacinia leo at sodales. Etiam finibus porta diam a viverra. Praesent nisi urna, volutpat sit amet odio at, vehicula vehicula leo. In non enim eget nisl luctus commodo. Pellentesque pellentesque at lectus at luctus. Quisque nec felis bibendum, lacinia libero ut, lacinia eros. Integer finibus ultricies nibh sit amet placerat. - -Nullam scelerisque velit et tortor congue vestibulum a at nisi. Vivamus sodales ut turpis a convallis. In dignissim nibh at luctus sodales. Etiam sit amet rhoncus massa. Phasellus ligula magna, sollicitudin non imperdiet sit amet, volutpat vel magna. Nunc vestibulum tempor lectus, sit amet porta nunc hendrerit in. Curabitur non odio sit amet massa tincidunt facilisis. Integer et luctus nunc, eget euismod leo. Praesent faucibus metus sed purus convallis scelerisque. Fusce viverra lorem et placerat malesuada. In at elit malesuada, ullamcorper risus vitae, sodales dolor. Donec quis elementum lectus. Quisque eu eros at dui imperdiet euismod ut id neque. - -# Notes: Plot - -# Main Plot - -**Tag:** Main - -Suspendisse vulputate malesuada pellentesque. Aenean sollicitudin cursus mi, vitae ultricies felis ullamcorper eu. Duis luctus risus mi, in accumsan velit cursus ut. Vestibulum eleifend leo in magna eleifend fermentum. Proin nec ornare elit. Phasellus nec interdum risus. In a volutpat augue, quis egestas justo. Morbi porta mauris mattis bibendum imperdiet. - -Mauris ut erat eu lorem malesuada egestas vel vel urna. Maecenas ac semper quam. Maecenas aliquet metus non interdum mattis. Proin consectetur molestie ligula. Aliquam sollicitudin pulvinar urna a pellentesque. Suspendisse ultrices, est mattis scelerisque porta, nisi nisi laoreet nisl, non condimentum quam ante a velit. Proin scelerisque justo augue, nec laoreet ligula egestas at. Etiam enim quam, ultrices non accumsan hendrerit, elementum vel ligula. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Nam efficitur odio libero, in vestibulum arcu aliquam at. Cras non vehicula augue. Integer lobortis, est vitae aliquam facilisis, metus ligula aliquet eros, at porttitor sem tortor eget massa. Aliquam varius scelerisque neque sed gravida. Aenean eleifend lorem id ante elementum sollicitudin. Proin commodo massa a quam volutpat, mollis fermentum turpis efficitur. - -# Notes: World - -# Ancient Europe - -**Tag:** Europe - -Vivamus sodales risus ac accumsan posuere. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nunc vel enim felis. Vestibulum dignissim massa nunc, a auctor magna eleifend et. Proin dignissim sodales erat vitae convallis. Aliquam id tellus dui. Curabitur sollicitudin scelerisque ex sit amet posuere. Nam rutrum felis id rhoncus feugiat. Duis sagittis quam quis purus efficitur, quis rutrum odio iaculis. Maecenas semper ante turpis, at vulputate mi consectetur non. Sed rutrum nibh turpis, quis rhoncus purus ornare quis. Vestibulum at rutrum mauris. Integer dolor nisi, tincidunt eget vehicula ac, ultricies at ligula. - -Aenean semper turpis quis varius rhoncus. Vivamus ac mi eget felis euismod vulputate. Nam eu tempus velit. Etiam ut est porta, finibus erat sit amet, consectetur felis. Nullam consequat felis ut lacus pharetra, in lobortis urna mollis. Nulla varius eros nec lorem rhoncus, sed venenatis risus ultrices. Phasellus pellentesque laoreet neque, ut ultricies lacus vulputate quis. In malesuada dui sit amet est interdum, eget consectetur mi gravida. Cras vel bibendum purus. Quisque commodo tempor arcu, non lacinia sem blandit eleifend. Quisque at neque gravida, porttitor metus a, suscipit diam. Quisque convallis sodales lacus et condimentum. Donec a suscipit diam. Pellentesque eget cursus neque. - -Nunc ullamcorper magna quis elit condimentum rhoncus. Aenean dictum pulvinar dolor suscipit interdum. Aliquam elit massa, elementum nec cursus eu, maximus nec ipsum. Donec ullamcorper iaculis dolor eu commodo. Nunc eget tortor quis turpis consectetur varius. Vestibulum nec justo vel tellus venenatis condimentum. Duis auctor iaculis massa. Nunc risus magna, rutrum vitae eros non, tristique mollis enim. - diff --git a/tests/reference/guiBuild_Tool_Step2_Lorem_Ipsum.nwd b/tests/reference/guiBuild_Tool_Step2_Lorem_Ipsum.nwd deleted file mode 100644 index f0e030c7..00000000 --- a/tests/reference/guiBuild_Tool_Step2_Lorem_Ipsum.nwd +++ /dev/null @@ -1,184 +0,0 @@ -#! Lorem Ipsum - -**By lipsum.com** - -“Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit…” - -“There is no one who loves pain itself, who seeks after it and wants to have it, simply because it is pain…” - - -% Exctracted from the lipsum.com website. - -Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of “de Finibus Bonorum et Malorum” (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, “Lorem ipsum dolor sit amet..”, comes from a line in section 1.10.32. - -The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from “de Finibus Bonorum et Malorum” by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham. - -##! Prologue - -% Synopsis:Explanation from the lipsum.com website. - -_Lorem Ipsum_ is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. - -# Act One - -“Fusce maximus felis libero” - -## Chapter One - -@pov: Bod -@plot: Main -@location: Europe - -% Synopsis: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam. - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam. Praesent magna nunc, lacinia sit amet quam eget, aliquet ultrices justo. Morbi ornare enim et lorem rutrum finibus ut eu dolor. Aliquam a orci odio. Ut ultrices sem quis massa placerat, eget mollis nisl cursus. Cras vel sagittis justo. Ut non ultricies leo. Maecenas rutrum velit in est varius, et egestas massa pulvinar. - -### Scene One - -@pov: Bod -@plot: Main -@location: Europe - -% Synopsis: Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur. - -Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur. Donec quis ante nunc. Mauris ut leo ipsum. Vestibulum est neque, hendrerit nec neque a, ullamcorper lobortis tellus. Fusce sollicitudin purus quis congue bibendum. Aliquam condimentum ipsum tristique blandit tristique. Donec pulvinar neque ac suscipit malesuada. - -Aliquam ut nisl arcu. Ut ultricies, lorem dignissim rutrum convallis, risus orci tempus lectus, congue feugiat sem lectus vitae odio. Duis sit amet justo finibus, hendrerit nulla at, ullamcorper enim. Praesent vel tellus sit amet tellus vulputate bibendum. Morbi eleifend sagittis sem, ac volutpat ante congue non. In hac habitasse platea dictumst. Morbi lobortis fermentum elit, dignissim sagittis ligula volutpat lacinia. Vestibulum eu interdum odio. Integer ac purus commodo metus congue tempor non at urna. Sed eget tortor vel quam viverra egestas. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Donec non convallis mauris, ac feugiat ex. - -#### Scene One, Section Two - -Integer vel libero ipsum. Donec varius aliquam libero, sit amet commodo urna hendrerit non. Nullam quis erat mollis nunc viverra volutpat tincidunt in odio. Nam vitae quam sem. Aliquam suscipit nulla non lorem pharetra semper. Ut suscipit erat eu ligula accumsan ultrices. Phasellus nisl tellus, placerat sed laoreet id, consectetur nec dolor. Sed fringilla ipsum id dapibus posuere. Aenean finibus pharetra tincidunt. Ut molestie malesuada nulla, id posuere lorem tincidunt eu. Aliquam tempor eros a est vulputate, scelerisque pulvinar ipsum fermentum. In hac habitasse platea dictumst. - -Curabitur congue, justo quis interdum fermentum, tellus nulla imperdiet sapien, eu interdum enim tellus condimentum metus. Vivamus nunc velit, dignissim ut ultrices sit amet, ultricies quis enim. Donec ut vestibulum neque. Vivamus semper neque id ex ullamcorper varius. Fusce mattis nibh viverra lorem sagittis, et tempor arcu congue. Suspendisse sit amet felis sed urna facilisis mattis eget vitae arcu. Proin eu magna hendrerit, tristique sem maximus, placerat diam. Nulla tristique sed velit sit amet varius. Etiam vel ornare magna, in vulputate arcu. Cras velit orci, tincidunt sed volutpat cursus, bibendum vel sem. Nunc vulputate pharetra tortor, ac consectetur neque tincidunt sit amet. Nulla ornare mi sed mi dignissim ultricies. Ut tincidunt bibendum mauris, sed elementum ex vulputate vel. Mauris fermentum, felis nec vehicula congue, felis lorem facilisis erat, a dictum dolor augue vitae quam. Maecenas rutrum tortor nec consequat eleifend. - -### Scene Two - -@pov: Bod -@plot: Main -@location: Europe - -% Synopsis: Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci. - -Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci. Vestibulum facilisis bibendum aliquam. Aliquam posuere, turpis ac bibendum varius, sem tellus venenatis risus, in elementum massa enim ac lorem. Integer in sem ac diam blandit ultricies ut in nulla. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam sit amet erat est. Curabitur vitae cursus justo, sit amet placerat dolor. Vivamus eu felis hendrerit, tincidunt massa rutrum, maximus arcu. Pellentesque commodo justo odio, vel rutrum nulla tincidunt eu. Integer non neque condimentum, convallis diam non, varius ligula. Aliquam eget sapien mauris. Aenean pharetra nunc nisi, vel maximus ante tristique sit amet. Aliquam risus metus, interdum non odio eu, consectetur lacinia sapien. - -Proin vitae gravida nisl. Integer viverra orci turpis, sit amet pretium ligula facilisis consequat. Nulla interdum commodo metus, mollis consequat dui tincidunt et. Proin consequat bibendum justo id commodo. Fusce fermentum nunc turpis, eu vestibulum risus feugiat ut. Sed scelerisque vel ligula ut interdum. Suspendisse ac blandit ligula, sagittis fringilla dolor. In tincidunt convallis diam et ornare. Aenean id dignissim est, ut rhoncus quam. Donec vitae nisl velit. In convallis nibh ut augue dignissim, eu elementum quam cursus. Phasellus in lectus lorem. Curabitur in pellentesque nisi, at gravida sapien. Sed cursus justo volutpat lacus placerat, sit amet dignissim turpis commodo. Aliquam vitae orci eget nulla posuere condimentum in ut felis. - -Nulla accumsan ante in pulvinar efficitur. Nulla non velit quis urna hendrerit bibendum. Suspendisse ultrices ante eu justo malesuada, sed fermentum enim rutrum. Nunc fermentum pharetra felis, vitae sollicitudin quam rutrum porta. Aliquam fringilla velit a mi laoreet, et luctus est rutrum. In gravida non ipsum sit amet tempus. Curabitur et eleifend purus. Nulla facilisi. - -#### Scene Two, Section Two - -Suspendisse potenti. Fusce tempus lorem nec laoreet suscipit. Fusce vulputate nisl ac diam tincidunt, nec malesuada quam pellentesque. Maecenas congue, tellus quis commodo rutrum, magna leo egestas arcu, quis suscipit ex risus id ligula. Suspendisse potenti. Morbi blandit lacus vitae laoreet vulputate. Donec vitae tellus eleifend, lobortis eros eu, tincidunt enim. Nullam et ullamcorper nisi. Vivamus tellus ex, lobortis quis rutrum ut, dapibus sit amet turpis. Phasellus pellentesque metus diam, commodo tristique ante commodo ac. Ut mollis ipsum nec diam blandit sollicitudin. Duis bibendum lacus nec commodo dapibus. Sed condimentum luctus ante, id ultricies urna varius nec. Nam convallis magna nec bibendum ultrices. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed auctor pharetra quam, vitae porta ex bibendum eu. - -Vivamus ut venenatis lectus. Phasellus nec elit id sem dictum ornare. Quisque feugiat, diam eget sagittis ultricies, orci turpis efficitur nisi, et fringilla justo odio nec nibh. In hac habitasse platea dictumst. Sed tempus bibendum feugiat. Etiam luctus mauris arcu, non interdum ipsum ultrices id. Vivamus blandit urna sit amet scelerisque vulputate. Quisque in metus eget massa rutrum dictum sit amet sed nulla. Vivamus vel efficitur dolor. - -Ut et consequat enim, quis ornare nibh. In lectus neque, mollis et suscipit et, vestibulum vitae augue. Praesent id ante sit amet odio venenatis placerat a at erat. Sed sed metus sed nisi dictum varius. Integer tincidunt fermentum purus ac porta. Fusce porttitor non risus eget tristique. Donec augue nunc, maximus at fermentum vel, varius et neque. Ut sed consectetur mauris. Quisque ipsum enim, porttitor vitae imperdiet sit amet, tempor et mauris. Aliquam malesuada tincidunt lectus quis blandit. Sed commodo orci felis, quis ultrices tellus facilisis sed. Nunc vel varius est. Duis ullamcorper eu metus in pulvinar. Morbi at sapien dictum, rutrum mauris eget, interdum tellus. - -##! Why do we use it? - -% Exctracted from the lipsum.com website. - - It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout. - - The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, as opposed to using 'Content here, content here', making it look like readable English. - - Many desktop publishing packages and web page editors now use Lorem Ipsum as their default model text, and a search for 'lorem ipsum' will uncover many web sites still in their infancy. Various versions have evolved over the years, sometimes by accident, sometimes on purpose (injected humour and the like). - -## Chapter Two - -@pov: Bod -@plot: Main -@location: Europe - -% Synopsis: Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue. - -Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue. Vestibulum pharetra fermentum leo, sed faucibus eros placerat quis. In hac habitasse platea dictumst. Donec metus massa, rutrum quis consequat et, tincidunt ac felis. Duis mollis metus ac nunc tincidunt blandit. Ut aliquet velit eu odio pharetra condimentum. Integer rutrum lacus orci, id venenatis libero accumsan at. - -### Scene Three - -@pov: Bod -@plot: Main -@location: Europe - -% Synopsis: Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. - -Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Aenean tincidunt lacus vitae nibh elementum eleifend. Sed rutrum condimentum sem quis blandit. Duis imperdiet libero metus, quis convallis quam faucibus a. Nulla ligula est, semper quis sollicitudin et, pretium id justo. Curabitur pharetra risus eget consectetur commodo. Duis mattis arcu non est condimentum, id venenatis risus volutpat. Pellentesque aliquet mauris non mauris porttitor ultrices. Phasellus ut vestibulum mi. Suspendisse malesuada metus lorem, a malesuada orci rhoncus a. Praesent euismod convallis ante, lacinia tincidunt ex egestas id. Praesent sit amet efficitur sapien. Morbi tincidunt volutpat nunc sed dictum. Aliquam ultrices metus id fermentum lobortis. - -Pellentesque id sagittis dui. Praesent ut nisi sit amet libero euismod ornare. Vestibulum vehicula, lorem eget aliquet imperdiet, eros nulla iaculis mi, vel bibendum est dui sed orci. Nullam vitae lorem rutrum, euismod lacus id, ullamcorper lectus. Duis nec commodo mi, a fringilla diam. Vestibulum molestie nibh tristique, viverra augue non, aliquet metus. Phasellus a tellus ac nisl tempor aliquet. Nulla vitae sapien rutrum augue ornare ultrices a quis nisi. Sed pulvinar tincidunt ex. Fusce vel sem vitae ante pellentesque lobortis. - -Maecenas ullamcorper lacus nec turpis finibus aliquet eget rutrum augue. Integer lorem erat, faucibus non lacus lacinia, pulvinar egestas felis. Proin rutrum nunc eget nulla varius, id blandit mauris tincidunt. Donec sit amet ullamcorper nisi, ut efficitur mi. Aliquam aliquet, nulla eget rhoncus tristique, justo lorem consectetur dui, id ornare leo odio sed tellus. Curabitur interdum velit a turpis condimentum venenatis. Nunc rhoncus sem ac augue auctor, nec malesuada ex fringilla. Vestibulum egestas diam sed leo consectetur vulputate quis eget enim. Nam tincidunt metus sit amet maximus ullamcorper. Sed placerat velit vitae massa efficitur viverra. Etiam eleifend dignissim ante, sed luctus nisl tristique a. In vestibulum pharetra dolor in molestie. Vivamus auctor massa ac magna imperdiet, sit amet iaculis turpis finibus. - -Aenean dapibus vulputate purus, sit amet tempor nunc suscipit consequat. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Mauris auctor congue eros, non pellentesque neque dapibus ac. Vestibulum non leo nec urna lacinia eleifend quis et diam. Praesent eu nisi magna. Nulla at magna massa. Suspendisse porta varius scelerisque. Duis at auctor dolor, non dapibus urna. Nunc venenatis feugiat magna non molestie. Aliquam non ornare ex. Quisque eu ultrices velit, quis pellentesque eros. Phasellus eleifend, elit id imperdiet aliquam, nulla quam molestie turpis, at egestas odio ante et tortor. Suspendisse fringilla condimentum justo, at aliquet odio aliquam ac. - -### Scene Four - -@pov: Bod -@plot: Main -@location: Europe - -% Synopsis: Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo. - -Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo. Nullam viverra dui et auctor pretium. Ut ullamcorper velit urna, sed imperdiet massa convallis a. Suspendisse efficitur, ipsum nec cursus pulvinar, eros urna posuere diam, nec elementum mi felis vitae sapien. - -Duis efficitur metus pulvinar, molestie magna eget, feugiat dui. Fusce convallis vehicula ipsum convallis blandit. Duis eros risus, malesuada eu imperdiet in, hendrerit ac metus. Vestibulum id justo gravida, dignissim nibh non, iaculis diam. Fusce accumsan est ut massa porta ultricies. Nulla vitae justo in tortor laoreet mollis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin eu libero justo. Vivamus aliquet placerat est, et auctor eros posuere venenatis. Nunc quam diam, tincidunt ac aliquet in, fermentum sit amet lectus. Proin commodo tincidunt blandit. Quisque erat arcu, semper nec dui non, consectetur gravida ipsum. Nullam pretium consectetur elit at condimentum. - -Etiam sagittis, erat vitae accumsan tempor, neque augue scelerisque nulla, ut ultrices justo urna sit amet augue. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean at pulvinar tortor. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Cras vel porta quam. Nullam eu mauris mollis, vehicula justo vel, placerat sapien. Phasellus viverra elit et vestibulum pharetra. Vestibulum commodo fermentum leo, eu porta nisi aliquam eget. Nulla tempus porttitor nisi nec mollis. Nam non mollis turpis. Nam finibus leo a bibendum tincidunt. Donec commodo velit magna, ac semper sapien mattis id. Proin sem velit, lobortis quis ultricies id, pharetra et lectus. Vestibulum condimentum neque vitae mi dapibus mollis. Mauris luctus vel sapien vitae hendrerit. - -Aenean vestibulum magna placerat fermentum tempus. Nam auctor condimentum nunc, in elementum quam ornare a. Etiam in ipsum elit. Proin pharetra, dolor sollicitudin pellentesque congue, lorem dolor ultricies magna, non iaculis risus nisl dictum diam. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Vivamus vel euismod nibh, et lobortis dolor. Maecenas dui odio, gravida nec molestie ut, feugiat ut arcu. Pellentesque risus sapien, gravida a convallis quis, ullamcorper porttitor sapien. - -Donec ipsum eros, vestibulum sit amet cursus eget, iaculis quis dolor. Pellentesque magna augue, tristique dapibus mi vitae, molestie venenatis enim. Nam malesuada, turpis volutpat rhoncus ullamcorper, justo est eleifend orci, ut luctus risus ex rutrum arcu. Sed mi elit, feugiat rhoncus ornare sed, porta id leo. Pellentesque feugiat nulla tincidunt erat suscipit, eu congue lacus hendrerit. Morbi pulvinar enim sed consequat auctor. Ut eleifend enim sem, vitae euismod ex ultricies sit amet. Curabitur eu efficitur nisi, suscipit finibus sapien. In sodales blandit erat, vestibulum pulvinar ante volutpat nec. Vivamus dictum non libero at molestie. Donec sit amet neque in ante convallis pretium. Nunc vel iaculis dui. - -Phasellus eu nunc ut nunc faucibus laoreet. Aliquam at magna risus. Praesent lobortis, risus finibus semper varius, magna purus vestibulum eros, at pulvinar sapien enim a ex. In scelerisque malesuada ex, sit amet egestas neque condimentum sed. Praesent vulputate efficitur massa. Cras at accumsan ligula. In elementum lectus eget blandit dictum. Nam vitae libero ut justo eleifend rutrum ac nec arcu. Aliquam sodales in quam congue vestibulum. Aliquam in accumsan sapien. Quisque lobortis nisl nisi, vitae bibendum turpis efficitur sed. Vestibulum tempor nulla eget nisi convallis, blandit sagittis ipsum convallis. Donec odio nibh, ultrices quis odio in, mollis euismod libero. - -### Scene Five - -@pov: Bod -@plot: Main -@location: Europe - -% Synopsis: Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. - -Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. In sed felis auctor, rhoncus dui ac, consequat dolor. Integer volutpat libero sed nisl aliquet varius. Suspendisse et lorem sapien. Proin id ultrices nibh, ac suscipit diam. Suspendisse placerat varius porttitor. Curabitur elementum sed enim ultrices imperdiet. - -In ut lobortis lacus, nec luctus arcu. Vivamus condimentum sapien a ipsum malesuada sodales. Donec et vestibulum risus. Integer dictum euismod eros id tincidunt. Aliquam sagittis leo vitae consequat fermentum. Donec maximus ex eu ex iaculis porta. Praesent pharetra lacinia risus, et eleifend diam commodo non. Sed feugiat ipsum ut orci sagittis, quis faucibus lectus blandit. Sed tellus quam, gravida vitae laoreet quis, tempus lobortis dui. Vivamus semper accumsan ullamcorper. Praesent tempus pretium eros, non elementum risus. Pellentesque odio quam, auctor quis ex non, vulputate egestas dolor. Nunc luctus enim ut justo sodales consectetur. Sed aliquet a mauris vel posuere. - -Donec luctus lectus efficitur, blandit nisi vitae, dignissim tellus. Pellentesque euismod pharetra augue gravida hendrerit. Quisque nisi mi, mattis ac nisi non, maximus malesuada ante. Nulla lobortis, diam eu ornare ornare, tellus enim feugiat arcu, non vestibulum tortor nunc eu justo. Integer blandit felis justo, eu semper est scelerisque vel. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nam ultricies, nisi vel elementum commodo, nisl dolor tincidunt magna, sed varius est nunc at lectus. Aliquam dolor tortor, sodales placerat ultricies quis, sodales quis sapien. Duis ullamcorper sollicitudin risus at mattis. Integer consequat et nunc at condimentum. Pellentesque cursus congue augue, non suscipit lectus sodales ut. Nam a mi bibendum, blandit nisl eu, accumsan nunc. Aliquam a ex mauris. Sed nec sem quis arcu dignissim tempus eget et turpis. Ut sed ex nec ipsum ultrices lobortis. - -Pellentesque rhoncus pharetra eros, non mollis nisi pretium non. Mauris accumsan quis odio quis euismod. Maecenas ultrices, augue et aliquam tincidunt, erat tellus ornare ligula, quis ultrices turpis nibh vel justo. Fusce gravida odio tellus. In a congue diam. Mauris consequat ex id leo lacinia dictum. Fusce id sem sodales, ultrices sapien ac, convallis orci. Donec gravida nunc sit amet nisi hendrerit, sed porta enim aliquam. In hac habitasse platea dictumst. Cras a orci felis. Curabitur non felis nec urna maximus auctor ut ut nisi. Curabitur at turpis eleifend, blandit eros at, molestie odio. Phasellus euismod neque augue. - -Integer egestas maximus leo eu facilisis. Nunc rhoncus dignissim lectus eu lacinia. Praesent lacinia urna porttitor aliquam condimentum. Nulla eu eros dictum, dictum nunc vitae, sagittis nibh. Integer ante neque, consequat nec sollicitudin id, consectetur vitae dolor. Nullam volutpat sem orci, quis viverra magna auctor a. Suspendisse potenti. Maecenas commodo sed neque pellentesque vehicula. Sed luctus nisl risus, elementum semper purus interdum vel. Ut pulvinar, massa sit amet venenatis placerat, nunc lacus hendrerit odio, non aliquet nunc risus eu lectus. Maecenas feugiat semper ligula, id lobortis sem porta eu. Integer posuere elit magna, at mollis eros bibendum et. Ut imperdiet purus vel nulla aliquam maximus. Morbi sodales purus tellus, a rhoncus sem rutrum sit amet. Quisque risus sem, laoreet nec convallis nec, rutrum vitae justo. - -# Notes: Characters - -# Nobody Owens - -@tag: Bod -@plot: Main - -Pellentesque nec erat ut nulla posuere commodo. Curabitur nisi augue, imperdiet et porta imperdiet, efficitur id leo. Cras finibus arcu at nibh commodo congue. Proin suscipit placerat condimentum. Aenean ante enim, cursus id lorem a, blandit venenatis nibh. Maecenas suscipit porta elit, sit amet porta felis porttitor eu. Sed a dui nibh. Phasellus sed faucibus dui. Pellentesque felis nulla, ultrices non efficitur quis, rutrum id mi. Mauris tempus auctor nisl, in bibendum enim pellentesque sit amet. Proin nunc lacus, imperdiet nec posuere ac, interdum non lectus. - -Suspendisse faucibus est auctor orci mollis luctus. Praesent quis sodales neque. Interdum et malesuada fames ac ante ipsum primis in faucibus. Donec sodales rutrum mattis. In in sem ornare, consequat nulla ac, convallis arcu. Duis ac metus id felis commodo commodo sit amet eget diam. Curabitur rhoncus lacinia leo at sodales. Etiam finibus porta diam a viverra. Praesent nisi urna, volutpat sit amet odio at, vehicula vehicula leo. In non enim eget nisl luctus commodo. Pellentesque pellentesque at lectus at luctus. Quisque nec felis bibendum, lacinia libero ut, lacinia eros. Integer finibus ultricies nibh sit amet placerat. - -Nullam scelerisque velit et tortor congue vestibulum a at nisi. Vivamus sodales ut turpis a convallis. In dignissim nibh at luctus sodales. Etiam sit amet rhoncus massa. Phasellus ligula magna, sollicitudin non imperdiet sit amet, volutpat vel magna. Nunc vestibulum tempor lectus, sit amet porta nunc hendrerit in. Curabitur non odio sit amet massa tincidunt facilisis. Integer et luctus nunc, eget euismod leo. Praesent faucibus metus sed purus convallis scelerisque. Fusce viverra lorem et placerat malesuada. In at elit malesuada, ullamcorper risus vitae, sodales dolor. Donec quis elementum lectus. Quisque eu eros at dui imperdiet euismod ut id neque. - -# Notes: Plot - -# Main Plot - -@tag: Main - -Suspendisse vulputate malesuada pellentesque. Aenean sollicitudin cursus mi, vitae ultricies felis ullamcorper eu. Duis luctus risus mi, in accumsan velit cursus ut. Vestibulum eleifend leo in magna eleifend fermentum. Proin nec ornare elit. Phasellus nec interdum risus. In a volutpat augue, quis egestas justo. Morbi porta mauris mattis bibendum imperdiet. - -Mauris ut erat eu lorem malesuada egestas vel vel urna. Maecenas ac semper quam. Maecenas aliquet metus non interdum mattis. Proin consectetur molestie ligula. Aliquam sollicitudin pulvinar urna a pellentesque. Suspendisse ultrices, est mattis scelerisque porta, nisi nisi laoreet nisl, non condimentum quam ante a velit. Proin scelerisque justo augue, nec laoreet ligula egestas at. Etiam enim quam, ultrices non accumsan hendrerit, elementum vel ligula. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Nam efficitur odio libero, in vestibulum arcu aliquam at. Cras non vehicula augue. Integer lobortis, est vitae aliquam facilisis, metus ligula aliquet eros, at porttitor sem tortor eget massa. Aliquam varius scelerisque neque sed gravida. Aenean eleifend lorem id ante elementum sollicitudin. Proin commodo massa a quam volutpat, mollis fermentum turpis efficitur. - -# Notes: World - -# Ancient Europe - -@tag: Europe - -Vivamus sodales risus ac accumsan posuere. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nunc vel enim felis. Vestibulum dignissim massa nunc, a auctor magna eleifend et. Proin dignissim sodales erat vitae convallis. Aliquam id tellus dui. Curabitur sollicitudin scelerisque ex sit amet posuere. Nam rutrum felis id rhoncus feugiat. Duis sagittis quam quis purus efficitur, quis rutrum odio iaculis. Maecenas semper ante turpis, at vulputate mi consectetur non. Sed rutrum nibh turpis, quis rhoncus purus ornare quis. Vestibulum at rutrum mauris. Integer dolor nisi, tincidunt eget vehicula ac, ultricies at ligula. - -Aenean semper turpis quis varius rhoncus. Vivamus ac mi eget felis euismod vulputate. Nam eu tempus velit. Etiam ut est porta, finibus erat sit amet, consectetur felis. Nullam consequat felis ut lacus pharetra, in lobortis urna mollis. Nulla varius eros nec lorem rhoncus, sed venenatis risus ultrices. Phasellus pellentesque laoreet neque, ut ultricies lacus vulputate quis. In malesuada dui sit amet est interdum, eget consectetur mi gravida. Cras vel bibendum purus. Quisque commodo tempor arcu, non lacinia sem blandit eleifend. Quisque at neque gravida, porttitor metus a, suscipit diam. Quisque convallis sodales lacus et condimentum. Donec a suscipit diam. Pellentesque eget cursus neque. - -Nunc ullamcorper magna quis elit condimentum rhoncus. Aenean dictum pulvinar dolor suscipit interdum. Aliquam elit massa, elementum nec cursus eu, maximus nec ipsum. Donec ullamcorper iaculis dolor eu commodo. Nunc eget tortor quis turpis consectetur varius. Vestibulum nec justo vel tellus venenatis condimentum. Duis auctor iaculis massa. Nunc risus magna, rutrum vitae eros non, tristique mollis enim. - diff --git a/tests/reference/guiBuild_Tool_Step3_Lorem_Ipsum.fodt b/tests/reference/guiBuild_Tool_Step3_Lorem_Ipsum.fodt deleted file mode 100644 index d5891721..00000000 --- a/tests/reference/guiBuild_Tool_Step3_Lorem_Ipsum.fodt +++ /dev/null @@ -1,209 +0,0 @@ - - - - 2023-05-29T22:11:58 - novelWriter/2.0.7 - lipsum.com - 40 - P0DT0H31M45S - Lorem Ipsum - 2023-05-29T22:11:58 - lipsum.com - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Lorem Ipsum / lipsum.com / 2 - - - - - - - - - Lorem Ipsum - By lipsum.com - “Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit…” - “There is no one who loves pain itself, who seeks after it and wants to have it, simply because it is pain…” - Comment: Exctracted from the lipsum.com website. - Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of “de Finibus Bonorum et Malorum” (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, “Lorem ipsum dolor sit amet..”, comes from a line in section 1.10.32. - The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from “de Finibus Bonorum et Malorum” by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham. - Prologue - Synopsis: Explanation from the lipsum.com website. - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. - Act One - “Fusce maximus felis libero” - Chapter One: Chapter One - Point of View: Bod - Plot: Main - Locations: Europe - Synopsis: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam. - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam. Praesent magna nunc, lacinia sit amet quam eget, aliquet ultrices justo. Morbi ornare enim et lorem rutrum finibus ut eu dolor. Aliquam a orci odio. Ut ultrices sem quis massa placerat, eget mollis nisl cursus. Cras vel sagittis justo. Ut non ultricies leo. Maecenas rutrum velit in est varius, et egestas massa pulvinar. - Scene 1.1: Scene One - Point of View: Bod - Plot: Main - Locations: Europe - Synopsis: Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur. - Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur. Donec quis ante nunc. Mauris ut leo ipsum. Vestibulum est neque, hendrerit nec neque a, ullamcorper lobortis tellus. Fusce sollicitudin purus quis congue bibendum. Aliquam condimentum ipsum tristique blandit tristique. Donec pulvinar neque ac suscipit malesuada. - Aliquam ut nisl arcu. Ut ultricies, lorem dignissim rutrum convallis, risus orci tempus lectus, congue feugiat sem lectus vitae odio. Duis sit amet justo finibus, hendrerit nulla at, ullamcorper enim. Praesent vel tellus sit amet tellus vulputate bibendum. Morbi eleifend sagittis sem, ac volutpat ante congue non. In hac habitasse platea dictumst. Morbi lobortis fermentum elit, dignissim sagittis ligula volutpat lacinia. Vestibulum eu interdum odio. Integer ac purus commodo metus congue tempor non at urna. Sed eget tortor vel quam viverra egestas. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Donec non convallis mauris, ac feugiat ex. - 1.1.1: Scene One, Section Two - Integer vel libero ipsum. Donec varius aliquam libero, sit amet commodo urna hendrerit non. Nullam quis erat mollis nunc viverra volutpat tincidunt in odio. Nam vitae quam sem. Aliquam suscipit nulla non lorem pharetra semper. Ut suscipit erat eu ligula accumsan ultrices. Phasellus nisl tellus, placerat sed laoreet id, consectetur nec dolor. Sed fringilla ipsum id dapibus posuere. Aenean finibus pharetra tincidunt. Ut molestie malesuada nulla, id posuere lorem tincidunt eu. Aliquam tempor eros a est vulputate, scelerisque pulvinar ipsum fermentum. In hac habitasse platea dictumst. - Curabitur congue, justo quis interdum fermentum, tellus nulla imperdiet sapien, eu interdum enim tellus condimentum metus. Vivamus nunc velit, dignissim ut ultrices sit amet, ultricies quis enim. Donec ut vestibulum neque. Vivamus semper neque id ex ullamcorper varius. Fusce mattis nibh viverra lorem sagittis, et tempor arcu congue. Suspendisse sit amet felis sed urna facilisis mattis eget vitae arcu. Proin eu magna hendrerit, tristique sem maximus, placerat diam. Nulla tristique sed velit sit amet varius. Etiam vel ornare magna, in vulputate arcu. Cras velit orci, tincidunt sed volutpat cursus, bibendum vel sem. Nunc vulputate pharetra tortor, ac consectetur neque tincidunt sit amet. Nulla ornare mi sed mi dignissim ultricies. Ut tincidunt bibendum mauris, sed elementum ex vulputate vel. Mauris fermentum, felis nec vehicula congue, felis lorem facilisis erat, a dictum dolor augue vitae quam. Maecenas rutrum tortor nec consequat eleifend. - Scene 1.2: Scene Two - Point of View: Bod - Plot: Main - Locations: Europe - Synopsis: Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci. - Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci. Vestibulum facilisis bibendum aliquam. Aliquam posuere, turpis ac bibendum varius, sem tellus venenatis risus, in elementum massa enim ac lorem. Integer in sem ac diam blandit ultricies ut in nulla. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam sit amet erat est. Curabitur vitae cursus justo, sit amet placerat dolor. Vivamus eu felis hendrerit, tincidunt massa rutrum, maximus arcu. Pellentesque commodo justo odio, vel rutrum nulla tincidunt eu. Integer non neque condimentum, convallis diam non, varius ligula. Aliquam eget sapien mauris. Aenean pharetra nunc nisi, vel maximus ante tristique sit amet. Aliquam risus metus, interdum non odio eu, consectetur lacinia sapien. - Proin vitae gravida nisl. Integer viverra orci turpis, sit amet pretium ligula facilisis consequat. Nulla interdum commodo metus, mollis consequat dui tincidunt et. Proin consequat bibendum justo id commodo. Fusce fermentum nunc turpis, eu vestibulum risus feugiat ut. Sed scelerisque vel ligula ut interdum. Suspendisse ac blandit ligula, sagittis fringilla dolor. In tincidunt convallis diam et ornare. Aenean id dignissim est, ut rhoncus quam. Donec vitae nisl velit. In convallis nibh ut augue dignissim, eu elementum quam cursus. Phasellus in lectus lorem. Curabitur in pellentesque nisi, at gravida sapien. Sed cursus justo volutpat lacus placerat, sit amet dignissim turpis commodo. Aliquam vitae orci eget nulla posuere condimentum in ut felis. - Nulla accumsan ante in pulvinar efficitur. Nulla non velit quis urna hendrerit bibendum. Suspendisse ultrices ante eu justo malesuada, sed fermentum enim rutrum. Nunc fermentum pharetra felis, vitae sollicitudin quam rutrum porta. Aliquam fringilla velit a mi laoreet, et luctus est rutrum. In gravida non ipsum sit amet tempus. Curabitur et eleifend purus. Nulla facilisi. - 1.2.1: Scene Two, Section Two - Suspendisse potenti. Fusce tempus lorem nec laoreet suscipit. Fusce vulputate nisl ac diam tincidunt, nec malesuada quam pellentesque. Maecenas congue, tellus quis commodo rutrum, magna leo egestas arcu, quis suscipit ex risus id ligula. Suspendisse potenti. Morbi blandit lacus vitae laoreet vulputate. Donec vitae tellus eleifend, lobortis eros eu, tincidunt enim. Nullam et ullamcorper nisi. Vivamus tellus ex, lobortis quis rutrum ut, dapibus sit amet turpis. Phasellus pellentesque metus diam, commodo tristique ante commodo ac. Ut mollis ipsum nec diam blandit sollicitudin. Duis bibendum lacus nec commodo dapibus. Sed condimentum luctus ante, id ultricies urna varius nec. Nam convallis magna nec bibendum ultrices. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed auctor pharetra quam, vitae porta ex bibendum eu. - Vivamus ut venenatis lectus. Phasellus nec elit id sem dictum ornare. Quisque feugiat, diam eget sagittis ultricies, orci turpis efficitur nisi, et fringilla justo odio nec nibh. In hac habitasse platea dictumst. Sed tempus bibendum feugiat. Etiam luctus mauris arcu, non interdum ipsum ultrices id. Vivamus blandit urna sit amet scelerisque vulputate. Quisque in metus eget massa rutrum dictum sit amet sed nulla. Vivamus vel efficitur dolor. - Ut et consequat enim, quis ornare nibh. In lectus neque, mollis et suscipit et, vestibulum vitae augue. Praesent id ante sit amet odio venenatis placerat a at erat. Sed sed metus sed nisi dictum varius. Integer tincidunt fermentum purus ac porta. Fusce porttitor non risus eget tristique. Donec augue nunc, maximus at fermentum vel, varius et neque. Ut sed consectetur mauris. Quisque ipsum enim, porttitor vitae imperdiet sit amet, tempor et mauris. Aliquam malesuada tincidunt lectus quis blandit. Sed commodo orci felis, quis ultrices tellus facilisis sed. Nunc vel varius est. Duis ullamcorper eu metus in pulvinar. Morbi at sapien dictum, rutrum mauris eget, interdum tellus. - Why do we use it? - Comment: Exctracted from the lipsum.com website. - It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout. - The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, as opposed to using 'Content here, content here', making it look like readable English. - Many desktop publishing packages and web page editors now use Lorem Ipsum as their default model text, and a search for 'lorem ipsum' will uncover many web sites still in their infancy. Various versions have evolved over the years, sometimes by accident, sometimes on purpose (injected humour and the like). - Chapter Two: Chapter Two - Point of View: Bod - Plot: Main - Locations: Europe - Synopsis: Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue. - Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue. Vestibulum pharetra fermentum leo, sed faucibus eros placerat quis. In hac habitasse platea dictumst. Donec metus massa, rutrum quis consequat et, tincidunt ac felis. Duis mollis metus ac nunc tincidunt blandit. Ut aliquet velit eu odio pharetra condimentum. Integer rutrum lacus orci, id venenatis libero accumsan at. - Scene 2.1: Scene Three - Point of View: Bod - Plot: Main - Locations: Europe - Synopsis: Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. - Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Aenean tincidunt lacus vitae nibh elementum eleifend. Sed rutrum condimentum sem quis blandit. Duis imperdiet libero metus, quis convallis quam faucibus a. Nulla ligula est, semper quis sollicitudin et, pretium id justo. Curabitur pharetra risus eget consectetur commodo. Duis mattis arcu non est condimentum, id venenatis risus volutpat. Pellentesque aliquet mauris non mauris porttitor ultrices. Phasellus ut vestibulum mi. Suspendisse malesuada metus lorem, a malesuada orci rhoncus a. Praesent euismod convallis ante, lacinia tincidunt ex egestas id. Praesent sit amet efficitur sapien. Morbi tincidunt volutpat nunc sed dictum. Aliquam ultrices metus id fermentum lobortis. - Pellentesque id sagittis dui. Praesent ut nisi sit amet libero euismod ornare. Vestibulum vehicula, lorem eget aliquet imperdiet, eros nulla iaculis mi, vel bibendum est dui sed orci. Nullam vitae lorem rutrum, euismod lacus id, ullamcorper lectus. Duis nec commodo mi, a fringilla diam. Vestibulum molestie nibh tristique, viverra augue non, aliquet metus. Phasellus a tellus ac nisl tempor aliquet. Nulla vitae sapien rutrum augue ornare ultrices a quis nisi. Sed pulvinar tincidunt ex. Fusce vel sem vitae ante pellentesque lobortis. - Maecenas ullamcorper lacus nec turpis finibus aliquet eget rutrum augue. Integer lorem erat, faucibus non lacus lacinia, pulvinar egestas felis. Proin rutrum nunc eget nulla varius, id blandit mauris tincidunt. Donec sit amet ullamcorper nisi, ut efficitur mi. Aliquam aliquet, nulla eget rhoncus tristique, justo lorem consectetur dui, id ornare leo odio sed tellus. Curabitur interdum velit a turpis condimentum venenatis. Nunc rhoncus sem ac augue auctor, nec malesuada ex fringilla. Vestibulum egestas diam sed leo consectetur vulputate quis eget enim. Nam tincidunt metus sit amet maximus ullamcorper. Sed placerat velit vitae massa efficitur viverra. Etiam eleifend dignissim ante, sed luctus nisl tristique a. In vestibulum pharetra dolor in molestie. Vivamus auctor massa ac magna imperdiet, sit amet iaculis turpis finibus. - Aenean dapibus vulputate purus, sit amet tempor nunc suscipit consequat. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Mauris auctor congue eros, non pellentesque neque dapibus ac. Vestibulum non leo nec urna lacinia eleifend quis et diam. Praesent eu nisi magna. Nulla at magna massa. Suspendisse porta varius scelerisque. Duis at auctor dolor, non dapibus urna. Nunc venenatis feugiat magna non molestie. Aliquam non ornare ex. Quisque eu ultrices velit, quis pellentesque eros. Phasellus eleifend, elit id imperdiet aliquam, nulla quam molestie turpis, at egestas odio ante et tortor. Suspendisse fringilla condimentum justo, at aliquet odio aliquam ac. - Scene 2.2: Scene Four - Point of View: Bod - Plot: Main - Locations: Europe - Synopsis: Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo. - Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo. Nullam viverra dui et auctor pretium. Ut ullamcorper velit urna, sed imperdiet massa convallis a. Suspendisse efficitur, ipsum nec cursus pulvinar, eros urna posuere diam, nec elementum mi felis vitae sapien. - Duis efficitur metus pulvinar, molestie magna eget, feugiat dui. Fusce convallis vehicula ipsum convallis blandit. Duis eros risus, malesuada eu imperdiet in, hendrerit ac metus. Vestibulum id justo gravida, dignissim nibh non, iaculis diam. Fusce accumsan est ut massa porta ultricies. Nulla vitae justo in tortor laoreet mollis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin eu libero justo. Vivamus aliquet placerat est, et auctor eros posuere venenatis. Nunc quam diam, tincidunt ac aliquet in, fermentum sit amet lectus. Proin commodo tincidunt blandit. Quisque erat arcu, semper nec dui non, consectetur gravida ipsum. Nullam pretium consectetur elit at condimentum. - Etiam sagittis, erat vitae accumsan tempor, neque augue scelerisque nulla, ut ultrices justo urna sit amet augue. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean at pulvinar tortor. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Cras vel porta quam. Nullam eu mauris mollis, vehicula justo vel, placerat sapien. Phasellus viverra elit et vestibulum pharetra. Vestibulum commodo fermentum leo, eu porta nisi aliquam eget. Nulla tempus porttitor nisi nec mollis. Nam non mollis turpis. Nam finibus leo a bibendum tincidunt. Donec commodo velit magna, ac semper sapien mattis id. Proin sem velit, lobortis quis ultricies id, pharetra et lectus. Vestibulum condimentum neque vitae mi dapibus mollis. Mauris luctus vel sapien vitae hendrerit. - Aenean vestibulum magna placerat fermentum tempus. Nam auctor condimentum nunc, in elementum quam ornare a. Etiam in ipsum elit. Proin pharetra, dolor sollicitudin pellentesque congue, lorem dolor ultricies magna, non iaculis risus nisl dictum diam. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Vivamus vel euismod nibh, et lobortis dolor. Maecenas dui odio, gravida nec molestie ut, feugiat ut arcu. Pellentesque risus sapien, gravida a convallis quis, ullamcorper porttitor sapien. - Donec ipsum eros, vestibulum sit amet cursus eget, iaculis quis dolor. Pellentesque magna augue, tristique dapibus mi vitae, molestie venenatis enim. Nam malesuada, turpis volutpat rhoncus ullamcorper, justo est eleifend orci, ut luctus risus ex rutrum arcu. Sed mi elit, feugiat rhoncus ornare sed, porta id leo. Pellentesque feugiat nulla tincidunt erat suscipit, eu congue lacus hendrerit. Morbi pulvinar enim sed consequat auctor. Ut eleifend enim sem, vitae euismod ex ultricies sit amet. Curabitur eu efficitur nisi, suscipit finibus sapien. In sodales blandit erat, vestibulum pulvinar ante volutpat nec. Vivamus dictum non libero at molestie. Donec sit amet neque in ante convallis pretium. Nunc vel iaculis dui. - Phasellus eu nunc ut nunc faucibus laoreet. Aliquam at magna risus. Praesent lobortis, risus finibus semper varius, magna purus vestibulum eros, at pulvinar sapien enim a ex. In scelerisque malesuada ex, sit amet egestas neque condimentum sed. Praesent vulputate efficitur massa. Cras at accumsan ligula. In elementum lectus eget blandit dictum. Nam vitae libero ut justo eleifend rutrum ac nec arcu. Aliquam sodales in quam congue vestibulum. Aliquam in accumsan sapien. Quisque lobortis nisl nisi, vitae bibendum turpis efficitur sed. Vestibulum tempor nulla eget nisi convallis, blandit sagittis ipsum convallis. Donec odio nibh, ultrices quis odio in, mollis euismod libero. - Scene 2.3: Scene Five - Point of View: Bod - Plot: Main - Locations: Europe - Synopsis: Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. - Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. In sed felis auctor, rhoncus dui ac, consequat dolor. Integer volutpat libero sed nisl aliquet varius. Suspendisse et lorem sapien. Proin id ultrices nibh, ac suscipit diam. Suspendisse placerat varius porttitor. Curabitur elementum sed enim ultrices imperdiet. - In ut lobortis lacus, nec luctus arcu. Vivamus condimentum sapien a ipsum malesuada sodales. Donec et vestibulum risus. Integer dictum euismod eros id tincidunt. Aliquam sagittis leo vitae consequat fermentum. Donec maximus ex eu ex iaculis porta. Praesent pharetra lacinia risus, et eleifend diam commodo non. Sed feugiat ipsum ut orci sagittis, quis faucibus lectus blandit. Sed tellus quam, gravida vitae laoreet quis, tempus lobortis dui. Vivamus semper accumsan ullamcorper. Praesent tempus pretium eros, non elementum risus. Pellentesque odio quam, auctor quis ex non, vulputate egestas dolor. Nunc luctus enim ut justo sodales consectetur. Sed aliquet a mauris vel posuere. - Donec luctus lectus efficitur, blandit nisi vitae, dignissim tellus. Pellentesque euismod pharetra augue gravida hendrerit. Quisque nisi mi, mattis ac nisi non, maximus malesuada ante. Nulla lobortis, diam eu ornare ornare, tellus enim feugiat arcu, non vestibulum tortor nunc eu justo. Integer blandit felis justo, eu semper est scelerisque vel. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nam ultricies, nisi vel elementum commodo, nisl dolor tincidunt magna, sed varius est nunc at lectus. Aliquam dolor tortor, sodales placerat ultricies quis, sodales quis sapien. Duis ullamcorper sollicitudin risus at mattis. Integer consequat et nunc at condimentum. Pellentesque cursus congue augue, non suscipit lectus sodales ut. Nam a mi bibendum, blandit nisl eu, accumsan nunc. Aliquam a ex mauris. Sed nec sem quis arcu dignissim tempus eget et turpis. Ut sed ex nec ipsum ultrices lobortis. - Pellentesque rhoncus pharetra eros, non mollis nisi pretium non. Mauris accumsan quis odio quis euismod. Maecenas ultrices, augue et aliquam tincidunt, erat tellus ornare ligula, quis ultrices turpis nibh vel justo. Fusce gravida odio tellus. In a congue diam. Mauris consequat ex id leo lacinia dictum. Fusce id sem sodales, ultrices sapien ac, convallis orci. Donec gravida nunc sit amet nisi hendrerit, sed porta enim aliquam. In hac habitasse platea dictumst. Cras a orci felis. Curabitur non felis nec urna maximus auctor ut ut nisi. Curabitur at turpis eleifend, blandit eros at, molestie odio. Phasellus euismod neque augue. - Integer egestas maximus leo eu facilisis. Nunc rhoncus dignissim lectus eu lacinia. Praesent lacinia urna porttitor aliquam condimentum. Nulla eu eros dictum, dictum nunc vitae, sagittis nibh. Integer ante neque, consequat nec sollicitudin id, consectetur vitae dolor. Nullam volutpat sem orci, quis viverra magna auctor a. Suspendisse potenti. Maecenas commodo sed neque pellentesque vehicula. Sed luctus nisl risus, elementum semper purus interdum vel. Ut pulvinar, massa sit amet venenatis placerat, nunc lacus hendrerit odio, non aliquet nunc risus eu lectus. Maecenas feugiat semper ligula, id lobortis sem porta eu. Integer posuere elit magna, at mollis eros bibendum et. Ut imperdiet purus vel nulla aliquam maximus. Morbi sodales purus tellus, a rhoncus sem rutrum sit amet. Quisque risus sem, laoreet nec convallis nec, rutrum vitae justo. - Notes: Characters - Nobody Owens - Tag: Bod - Plot: Main - Pellentesque nec erat ut nulla posuere commodo. Curabitur nisi augue, imperdiet et porta imperdiet, efficitur id leo. Cras finibus arcu at nibh commodo congue. Proin suscipit placerat condimentum. Aenean ante enim, cursus id lorem a, blandit venenatis nibh. Maecenas suscipit porta elit, sit amet porta felis porttitor eu. Sed a dui nibh. Phasellus sed faucibus dui. Pellentesque felis nulla, ultrices non efficitur quis, rutrum id mi. Mauris tempus auctor nisl, in bibendum enim pellentesque sit amet. Proin nunc lacus, imperdiet nec posuere ac, interdum non lectus. - Suspendisse faucibus est auctor orci mollis luctus. Praesent quis sodales neque. Interdum et malesuada fames ac ante ipsum primis in faucibus. Donec sodales rutrum mattis. In in sem ornare, consequat nulla ac, convallis arcu. Duis ac metus id felis commodo commodo sit amet eget diam. Curabitur rhoncus lacinia leo at sodales. Etiam finibus porta diam a viverra. Praesent nisi urna, volutpat sit amet odio at, vehicula vehicula leo. In non enim eget nisl luctus commodo. Pellentesque pellentesque at lectus at luctus. Quisque nec felis bibendum, lacinia libero ut, lacinia eros. Integer finibus ultricies nibh sit amet placerat. - Nullam scelerisque velit et tortor congue vestibulum a at nisi. Vivamus sodales ut turpis a convallis. In dignissim nibh at luctus sodales. Etiam sit amet rhoncus massa. Phasellus ligula magna, sollicitudin non imperdiet sit amet, volutpat vel magna. Nunc vestibulum tempor lectus, sit amet porta nunc hendrerit in. Curabitur non odio sit amet massa tincidunt facilisis. Integer et luctus nunc, eget euismod leo. Praesent faucibus metus sed purus convallis scelerisque. Fusce viverra lorem et placerat malesuada. In at elit malesuada, ullamcorper risus vitae, sodales dolor. Donec quis elementum lectus. Quisque eu eros at dui imperdiet euismod ut id neque. - Notes: Plot - Main Plot - Tag: Main - Suspendisse vulputate malesuada pellentesque. Aenean sollicitudin cursus mi, vitae ultricies felis ullamcorper eu. Duis luctus risus mi, in accumsan velit cursus ut. Vestibulum eleifend leo in magna eleifend fermentum. Proin nec ornare elit. Phasellus nec interdum risus. In a volutpat augue, quis egestas justo. Morbi porta mauris mattis bibendum imperdiet. - Mauris ut erat eu lorem malesuada egestas vel vel urna. Maecenas ac semper quam. Maecenas aliquet metus non interdum mattis. Proin consectetur molestie ligula. Aliquam sollicitudin pulvinar urna a pellentesque. Suspendisse ultrices, est mattis scelerisque porta, nisi nisi laoreet nisl, non condimentum quam ante a velit. Proin scelerisque justo augue, nec laoreet ligula egestas at. Etiam enim quam, ultrices non accumsan hendrerit, elementum vel ligula. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Nam efficitur odio libero, in vestibulum arcu aliquam at. Cras non vehicula augue. Integer lobortis, est vitae aliquam facilisis, metus ligula aliquet eros, at porttitor sem tortor eget massa. Aliquam varius scelerisque neque sed gravida. Aenean eleifend lorem id ante elementum sollicitudin. Proin commodo massa a quam volutpat, mollis fermentum turpis efficitur. - Notes: World - Ancient Europe - Tag: Europe - Vivamus sodales risus ac accumsan posuere. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nunc vel enim felis. Vestibulum dignissim massa nunc, a auctor magna eleifend et. Proin dignissim sodales erat vitae convallis. Aliquam id tellus dui. Curabitur sollicitudin scelerisque ex sit amet posuere. Nam rutrum felis id rhoncus feugiat. Duis sagittis quam quis purus efficitur, quis rutrum odio iaculis. Maecenas semper ante turpis, at vulputate mi consectetur non. Sed rutrum nibh turpis, quis rhoncus purus ornare quis. Vestibulum at rutrum mauris. Integer dolor nisi, tincidunt eget vehicula ac, ultricies at ligula. - Aenean semper turpis quis varius rhoncus. Vivamus ac mi eget felis euismod vulputate. Nam eu tempus velit. Etiam ut est porta, finibus erat sit amet, consectetur felis. Nullam consequat felis ut lacus pharetra, in lobortis urna mollis. Nulla varius eros nec lorem rhoncus, sed venenatis risus ultrices. Phasellus pellentesque laoreet neque, ut ultricies lacus vulputate quis. In malesuada dui sit amet est interdum, eget consectetur mi gravida. Cras vel bibendum purus. Quisque commodo tempor arcu, non lacinia sem blandit eleifend. Quisque at neque gravida, porttitor metus a, suscipit diam. Quisque convallis sodales lacus et condimentum. Donec a suscipit diam. Pellentesque eget cursus neque. - Nunc ullamcorper magna quis elit condimentum rhoncus. Aenean dictum pulvinar dolor suscipit interdum. Aliquam elit massa, elementum nec cursus eu, maximus nec ipsum. Donec ullamcorper iaculis dolor eu commodo. Nunc eget tortor quis turpis consectetur varius. Vestibulum nec justo vel tellus venenatis condimentum. Duis auctor iaculis massa. Nunc risus magna, rutrum vitae eros non, tristique mollis enim. - - - diff --git a/tests/reference/guiBuild_Tool_Step3_Lorem_Ipsum.htm b/tests/reference/guiBuild_Tool_Step3_Lorem_Ipsum.htm deleted file mode 100644 index 1a945ea4..00000000 --- a/tests/reference/guiBuild_Tool_Step3_Lorem_Ipsum.htm +++ /dev/null @@ -1,126 +0,0 @@ - - - - -Lorem Ipsum - - - -
-

Lorem Ipsum

-

By lipsum.com

-

“Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit…”

-

“There is no one who loves pain itself, who seeks after it and wants to have it, simply because it is pain…”

-

Comment: Exctracted from the lipsum.com website.

-

Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of “de Finibus Bonorum et Malorum” (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, “Lorem ipsum dolor sit amet..”, comes from a line in section 1.10.32.

-

The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from “de Finibus Bonorum et Malorum” by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham.

-

Prologue

-

Synopsis: Explanation from the lipsum.com website.

-

Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.

-

Act One

-

“Fusce maximus felis libero”

-

Chapter One: Chapter One

-

Point of View: Bod

-

Plot: Main

-

Locations: Europe

-

Synopsis: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam.

-

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam. Praesent magna nunc, lacinia sit amet quam eget, aliquet ultrices justo. Morbi ornare enim et lorem rutrum finibus ut eu dolor. Aliquam a orci odio. Ut ultrices sem quis massa placerat, eget mollis nisl cursus. Cras vel sagittis justo. Ut non ultricies leo. Maecenas rutrum velit in est varius, et egestas massa pulvinar.

-

Scene 1.1: Scene One

-

Point of View: Bod

-

Plot: Main

-

Locations: Europe

-

Synopsis: Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur.

-

Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur. Donec quis ante nunc. Mauris ut leo ipsum. Vestibulum est neque, hendrerit nec neque a, ullamcorper lobortis tellus. Fusce sollicitudin purus quis congue bibendum. Aliquam condimentum ipsum tristique blandit tristique. Donec pulvinar neque ac suscipit malesuada.

-

Aliquam ut nisl arcu. Ut ultricies, lorem dignissim rutrum convallis, risus orci tempus lectus, congue feugiat sem lectus vitae odio. Duis sit amet justo finibus, hendrerit nulla at, ullamcorper enim. Praesent vel tellus sit amet tellus vulputate bibendum. Morbi eleifend sagittis sem, ac volutpat ante congue non. In hac habitasse platea dictumst. Morbi lobortis fermentum elit, dignissim sagittis ligula volutpat lacinia. Vestibulum eu interdum odio. Integer ac purus commodo metus congue tempor non at urna. Sed eget tortor vel quam viverra egestas. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Donec non convallis mauris, ac feugiat ex.

-

1.1.1: Scene One, Section Two

-

Integer vel libero ipsum. Donec varius aliquam libero, sit amet commodo urna hendrerit non. Nullam quis erat mollis nunc viverra volutpat tincidunt in odio. Nam vitae quam sem. Aliquam suscipit nulla non lorem pharetra semper. Ut suscipit erat eu ligula accumsan ultrices. Phasellus nisl tellus, placerat sed laoreet id, consectetur nec dolor. Sed fringilla ipsum id dapibus posuere. Aenean finibus pharetra tincidunt. Ut molestie malesuada nulla, id posuere lorem tincidunt eu. Aliquam tempor eros a est vulputate, scelerisque pulvinar ipsum fermentum. In hac habitasse platea dictumst.

-

Curabitur congue, justo quis interdum fermentum, tellus nulla imperdiet sapien, eu interdum enim tellus condimentum metus. Vivamus nunc velit, dignissim ut ultrices sit amet, ultricies quis enim. Donec ut vestibulum neque. Vivamus semper neque id ex ullamcorper varius. Fusce mattis nibh viverra lorem sagittis, et tempor arcu congue. Suspendisse sit amet felis sed urna facilisis mattis eget vitae arcu. Proin eu magna hendrerit, tristique sem maximus, placerat diam. Nulla tristique sed velit sit amet varius. Etiam vel ornare magna, in vulputate arcu. Cras velit orci, tincidunt sed volutpat cursus, bibendum vel sem. Nunc vulputate pharetra tortor, ac consectetur neque tincidunt sit amet. Nulla ornare mi sed mi dignissim ultricies. Ut tincidunt bibendum mauris, sed elementum ex vulputate vel. Mauris fermentum, felis nec vehicula congue, felis lorem facilisis erat, a dictum dolor augue vitae quam. Maecenas rutrum tortor nec consequat eleifend.

-

Scene 1.2: Scene Two

-

Point of View: Bod

-

Plot: Main

-

Locations: Europe

-

Synopsis: Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci.

-

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci. Vestibulum facilisis bibendum aliquam. Aliquam posuere, turpis ac bibendum varius, sem tellus venenatis risus, in elementum massa enim ac lorem. Integer in sem ac diam blandit ultricies ut in nulla. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam sit amet erat est. Curabitur vitae cursus justo, sit amet placerat dolor. Vivamus eu felis hendrerit, tincidunt massa rutrum, maximus arcu. Pellentesque commodo justo odio, vel rutrum nulla tincidunt eu. Integer non neque condimentum, convallis diam non, varius ligula. Aliquam eget sapien mauris. Aenean pharetra nunc nisi, vel maximus ante tristique sit amet. Aliquam risus metus, interdum non odio eu, consectetur lacinia sapien.

-

Proin vitae gravida nisl. Integer viverra orci turpis, sit amet pretium ligula facilisis consequat. Nulla interdum commodo metus, mollis consequat dui tincidunt et. Proin consequat bibendum justo id commodo. Fusce fermentum nunc turpis, eu vestibulum risus feugiat ut. Sed scelerisque vel ligula ut interdum. Suspendisse ac blandit ligula, sagittis fringilla dolor. In tincidunt convallis diam et ornare. Aenean id dignissim est, ut rhoncus quam. Donec vitae nisl velit. In convallis nibh ut augue dignissim, eu elementum quam cursus. Phasellus in lectus lorem. Curabitur in pellentesque nisi, at gravida sapien. Sed cursus justo volutpat lacus placerat, sit amet dignissim turpis commodo. Aliquam vitae orci eget nulla posuere condimentum in ut felis.

-

Nulla accumsan ante in pulvinar efficitur. Nulla non velit quis urna hendrerit bibendum. Suspendisse ultrices ante eu justo malesuada, sed fermentum enim rutrum. Nunc fermentum pharetra felis, vitae sollicitudin quam rutrum porta. Aliquam fringilla velit a mi laoreet, et luctus est rutrum. In gravida non ipsum sit amet tempus. Curabitur et eleifend purus. Nulla facilisi.

-

1.2.1: Scene Two, Section Two

-

Suspendisse potenti. Fusce tempus lorem nec laoreet suscipit. Fusce vulputate nisl ac diam tincidunt, nec malesuada quam pellentesque. Maecenas congue, tellus quis commodo rutrum, magna leo egestas arcu, quis suscipit ex risus id ligula. Suspendisse potenti. Morbi blandit lacus vitae laoreet vulputate. Donec vitae tellus eleifend, lobortis eros eu, tincidunt enim. Nullam et ullamcorper nisi. Vivamus tellus ex, lobortis quis rutrum ut, dapibus sit amet turpis. Phasellus pellentesque metus diam, commodo tristique ante commodo ac. Ut mollis ipsum nec diam blandit sollicitudin. Duis bibendum lacus nec commodo dapibus. Sed condimentum luctus ante, id ultricies urna varius nec. Nam convallis magna nec bibendum ultrices. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed auctor pharetra quam, vitae porta ex bibendum eu.

-

Vivamus ut venenatis lectus. Phasellus nec elit id sem dictum ornare. Quisque feugiat, diam eget sagittis ultricies, orci turpis efficitur nisi, et fringilla justo odio nec nibh. In hac habitasse platea dictumst. Sed tempus bibendum feugiat. Etiam luctus mauris arcu, non interdum ipsum ultrices id. Vivamus blandit urna sit amet scelerisque vulputate. Quisque in metus eget massa rutrum dictum sit amet sed nulla. Vivamus vel efficitur dolor.

-

Ut et consequat enim, quis ornare nibh. In lectus neque, mollis et suscipit et, vestibulum vitae augue. Praesent id ante sit amet odio venenatis placerat a at erat. Sed sed metus sed nisi dictum varius. Integer tincidunt fermentum purus ac porta. Fusce porttitor non risus eget tristique. Donec augue nunc, maximus at fermentum vel, varius et neque. Ut sed consectetur mauris. Quisque ipsum enim, porttitor vitae imperdiet sit amet, tempor et mauris. Aliquam malesuada tincidunt lectus quis blandit. Sed commodo orci felis, quis ultrices tellus facilisis sed. Nunc vel varius est. Duis ullamcorper eu metus in pulvinar. Morbi at sapien dictum, rutrum mauris eget, interdum tellus.

-

Why do we use it?

-

Comment: Exctracted from the lipsum.com website.

-

        It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.

-

        The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, as opposed to using 'Content here, content here', making it look like readable English.

-

        Many desktop publishing packages and web page editors now use Lorem Ipsum as their default model text, and a search for 'lorem ipsum' will uncover many web sites still in their infancy. Various versions have evolved over the years, sometimes by accident, sometimes on purpose (injected humour and the like).

-

Chapter Two: Chapter Two

-

Point of View: Bod

-

Plot: Main

-

Locations: Europe

-

Synopsis: Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue.

-

Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue. Vestibulum pharetra fermentum leo, sed faucibus eros placerat quis. In hac habitasse platea dictumst. Donec metus massa, rutrum quis consequat et, tincidunt ac felis. Duis mollis metus ac nunc tincidunt blandit. Ut aliquet velit eu odio pharetra condimentum. Integer rutrum lacus orci, id venenatis libero accumsan at.

-

Scene 2.1: Scene Three

-

Point of View: Bod

-

Plot: Main

-

Locations: Europe

-

Synopsis: Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.

-

Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Aenean tincidunt lacus vitae nibh elementum eleifend. Sed rutrum condimentum sem quis blandit. Duis imperdiet libero metus, quis convallis quam faucibus a. Nulla ligula est, semper quis sollicitudin et, pretium id justo. Curabitur pharetra risus eget consectetur commodo. Duis mattis arcu non est condimentum, id venenatis risus volutpat. Pellentesque aliquet mauris non mauris porttitor ultrices. Phasellus ut vestibulum mi. Suspendisse malesuada metus lorem, a malesuada orci rhoncus a. Praesent euismod convallis ante, lacinia tincidunt ex egestas id. Praesent sit amet efficitur sapien. Morbi tincidunt volutpat nunc sed dictum. Aliquam ultrices metus id fermentum lobortis.

-

Pellentesque id sagittis dui. Praesent ut nisi sit amet libero euismod ornare. Vestibulum vehicula, lorem eget aliquet imperdiet, eros nulla iaculis mi, vel bibendum est dui sed orci. Nullam vitae lorem rutrum, euismod lacus id, ullamcorper lectus. Duis nec commodo mi, a fringilla diam. Vestibulum molestie nibh tristique, viverra augue non, aliquet metus. Phasellus a tellus ac nisl tempor aliquet. Nulla vitae sapien rutrum augue ornare ultrices a quis nisi. Sed pulvinar tincidunt ex. Fusce vel sem vitae ante pellentesque lobortis.

-

Maecenas ullamcorper lacus nec turpis finibus aliquet eget rutrum augue. Integer lorem erat, faucibus non lacus lacinia, pulvinar egestas felis. Proin rutrum nunc eget nulla varius, id blandit mauris tincidunt. Donec sit amet ullamcorper nisi, ut efficitur mi. Aliquam aliquet, nulla eget rhoncus tristique, justo lorem consectetur dui, id ornare leo odio sed tellus. Curabitur interdum velit a turpis condimentum venenatis. Nunc rhoncus sem ac augue auctor, nec malesuada ex fringilla. Vestibulum egestas diam sed leo consectetur vulputate quis eget enim. Nam tincidunt metus sit amet maximus ullamcorper. Sed placerat velit vitae massa efficitur viverra. Etiam eleifend dignissim ante, sed luctus nisl tristique a. In vestibulum pharetra dolor in molestie. Vivamus auctor massa ac magna imperdiet, sit amet iaculis turpis finibus.

-

Aenean dapibus vulputate purus, sit amet tempor nunc suscipit consequat. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Mauris auctor congue eros, non pellentesque neque dapibus ac. Vestibulum non leo nec urna lacinia eleifend quis et diam. Praesent eu nisi magna. Nulla at magna massa. Suspendisse porta varius scelerisque. Duis at auctor dolor, non dapibus urna. Nunc venenatis feugiat magna non molestie. Aliquam non ornare ex. Quisque eu ultrices velit, quis pellentesque eros. Phasellus eleifend, elit id imperdiet aliquam, nulla quam molestie turpis, at egestas odio ante et tortor. Suspendisse fringilla condimentum justo, at aliquet odio aliquam ac.

-

Scene 2.2: Scene Four

-

Point of View: Bod

-

Plot: Main

-

Locations: Europe

-

Synopsis: Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo.

-

Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo. Nullam viverra dui et auctor pretium. Ut ullamcorper velit urna, sed imperdiet massa convallis a. Suspendisse efficitur, ipsum nec cursus pulvinar, eros urna posuere diam, nec elementum mi felis vitae sapien.

-

Duis efficitur metus pulvinar, molestie magna eget, feugiat dui. Fusce convallis vehicula ipsum convallis blandit. Duis eros risus, malesuada eu imperdiet in, hendrerit ac metus. Vestibulum id justo gravida, dignissim nibh non, iaculis diam. Fusce accumsan est ut massa porta ultricies. Nulla vitae justo in tortor laoreet mollis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin eu libero justo. Vivamus aliquet placerat est, et auctor eros posuere venenatis. Nunc quam diam, tincidunt ac aliquet in, fermentum sit amet lectus. Proin commodo tincidunt blandit. Quisque erat arcu, semper nec dui non, consectetur gravida ipsum. Nullam pretium consectetur elit at condimentum.

-

Etiam sagittis, erat vitae accumsan tempor, neque augue scelerisque nulla, ut ultrices justo urna sit amet augue. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean at pulvinar tortor. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Cras vel porta quam. Nullam eu mauris mollis, vehicula justo vel, placerat sapien. Phasellus viverra elit et vestibulum pharetra. Vestibulum commodo fermentum leo, eu porta nisi aliquam eget. Nulla tempus porttitor nisi nec mollis. Nam non mollis turpis. Nam finibus leo a bibendum tincidunt. Donec commodo velit magna, ac semper sapien mattis id. Proin sem velit, lobortis quis ultricies id, pharetra et lectus. Vestibulum condimentum neque vitae mi dapibus mollis. Mauris luctus vel sapien vitae hendrerit.

-

Aenean vestibulum magna placerat fermentum tempus. Nam auctor condimentum nunc, in elementum quam ornare a. Etiam in ipsum elit. Proin pharetra, dolor sollicitudin pellentesque congue, lorem dolor ultricies magna, non iaculis risus nisl dictum diam. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Vivamus vel euismod nibh, et lobortis dolor. Maecenas dui odio, gravida nec molestie ut, feugiat ut arcu. Pellentesque risus sapien, gravida a convallis quis, ullamcorper porttitor sapien.

-

Donec ipsum eros, vestibulum sit amet cursus eget, iaculis quis dolor. Pellentesque magna augue, tristique dapibus mi vitae, molestie venenatis enim. Nam malesuada, turpis volutpat rhoncus ullamcorper, justo est eleifend orci, ut luctus risus ex rutrum arcu. Sed mi elit, feugiat rhoncus ornare sed, porta id leo. Pellentesque feugiat nulla tincidunt erat suscipit, eu congue lacus hendrerit. Morbi pulvinar enim sed consequat auctor. Ut eleifend enim sem, vitae euismod ex ultricies sit amet. Curabitur eu efficitur nisi, suscipit finibus sapien. In sodales blandit erat, vestibulum pulvinar ante volutpat nec. Vivamus dictum non libero at molestie. Donec sit amet neque in ante convallis pretium. Nunc vel iaculis dui.

-

Phasellus eu nunc ut nunc faucibus laoreet. Aliquam at magna risus. Praesent lobortis, risus finibus semper varius, magna purus vestibulum eros, at pulvinar sapien enim a ex. In scelerisque malesuada ex, sit amet egestas neque condimentum sed. Praesent vulputate efficitur massa. Cras at accumsan ligula. In elementum lectus eget blandit dictum. Nam vitae libero ut justo eleifend rutrum ac nec arcu. Aliquam sodales in quam congue vestibulum. Aliquam in accumsan sapien. Quisque lobortis nisl nisi, vitae bibendum turpis efficitur sed. Vestibulum tempor nulla eget nisi convallis, blandit sagittis ipsum convallis. Donec odio nibh, ultrices quis odio in, mollis euismod libero.

-

Scene 2.3: Scene Five

-

Point of View: Bod

-

Plot: Main

-

Locations: Europe

-

Synopsis: Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.

-

Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. In sed felis auctor, rhoncus dui ac, consequat dolor. Integer volutpat libero sed nisl aliquet varius. Suspendisse et lorem sapien. Proin id ultrices nibh, ac suscipit diam. Suspendisse placerat varius porttitor. Curabitur elementum sed enim ultrices imperdiet.

-

In ut lobortis lacus, nec luctus arcu. Vivamus condimentum sapien a ipsum malesuada sodales. Donec et vestibulum risus. Integer dictum euismod eros id tincidunt. Aliquam sagittis leo vitae consequat fermentum. Donec maximus ex eu ex iaculis porta. Praesent pharetra lacinia risus, et eleifend diam commodo non. Sed feugiat ipsum ut orci sagittis, quis faucibus lectus blandit. Sed tellus quam, gravida vitae laoreet quis, tempus lobortis dui. Vivamus semper accumsan ullamcorper. Praesent tempus pretium eros, non elementum risus. Pellentesque odio quam, auctor quis ex non, vulputate egestas dolor. Nunc luctus enim ut justo sodales consectetur. Sed aliquet a mauris vel posuere.

-

Donec luctus lectus efficitur, blandit nisi vitae, dignissim tellus. Pellentesque euismod pharetra augue gravida hendrerit. Quisque nisi mi, mattis ac nisi non, maximus malesuada ante. Nulla lobortis, diam eu ornare ornare, tellus enim feugiat arcu, non vestibulum tortor nunc eu justo. Integer blandit felis justo, eu semper est scelerisque vel. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nam ultricies, nisi vel elementum commodo, nisl dolor tincidunt magna, sed varius est nunc at lectus. Aliquam dolor tortor, sodales placerat ultricies quis, sodales quis sapien. Duis ullamcorper sollicitudin risus at mattis. Integer consequat et nunc at condimentum. Pellentesque cursus congue augue, non suscipit lectus sodales ut. Nam a mi bibendum, blandit nisl eu, accumsan nunc. Aliquam a ex mauris. Sed nec sem quis arcu dignissim tempus eget et turpis. Ut sed ex nec ipsum ultrices lobortis.

-

Pellentesque rhoncus pharetra eros, non mollis nisi pretium non. Mauris accumsan quis odio quis euismod. Maecenas ultrices, augue et aliquam tincidunt, erat tellus ornare ligula, quis ultrices turpis nibh vel justo. Fusce gravida odio tellus. In a congue diam. Mauris consequat ex id leo lacinia dictum. Fusce id sem sodales, ultrices sapien ac, convallis orci. Donec gravida nunc sit amet nisi hendrerit, sed porta enim aliquam. In hac habitasse platea dictumst. Cras a orci felis. Curabitur non felis nec urna maximus auctor ut ut nisi. Curabitur at turpis eleifend, blandit eros at, molestie odio. Phasellus euismod neque augue.

-

Integer egestas maximus leo eu facilisis. Nunc rhoncus dignissim lectus eu lacinia. Praesent lacinia urna porttitor aliquam condimentum. Nulla eu eros dictum, dictum nunc vitae, sagittis nibh. Integer ante neque, consequat nec sollicitudin id, consectetur vitae dolor. Nullam volutpat sem orci, quis viverra magna auctor a. Suspendisse potenti. Maecenas commodo sed neque pellentesque vehicula. Sed luctus nisl risus, elementum semper purus interdum vel. Ut pulvinar, massa sit amet venenatis placerat, nunc lacus hendrerit odio, non aliquet nunc risus eu lectus. Maecenas feugiat semper ligula, id lobortis sem porta eu. Integer posuere elit magna, at mollis eros bibendum et. Ut imperdiet purus vel nulla aliquam maximus. Morbi sodales purus tellus, a rhoncus sem rutrum sit amet. Quisque risus sem, laoreet nec convallis nec, rutrum vitae justo.

-

Notes: Characters

-

Nobody Owens

-

Tag: Bod

-

Plot: Main

-

Pellentesque nec erat ut nulla posuere commodo. Curabitur nisi augue, imperdiet et porta imperdiet, efficitur id leo. Cras finibus arcu at nibh commodo congue. Proin suscipit placerat condimentum. Aenean ante enim, cursus id lorem a, blandit venenatis nibh. Maecenas suscipit porta elit, sit amet porta felis porttitor eu. Sed a dui nibh. Phasellus sed faucibus dui. Pellentesque felis nulla, ultrices non efficitur quis, rutrum id mi. Mauris tempus auctor nisl, in bibendum enim pellentesque sit amet. Proin nunc lacus, imperdiet nec posuere ac, interdum non lectus.

-

Suspendisse faucibus est auctor orci mollis luctus. Praesent quis sodales neque. Interdum et malesuada fames ac ante ipsum primis in faucibus. Donec sodales rutrum mattis. In in sem ornare, consequat nulla ac, convallis arcu. Duis ac metus id felis commodo commodo sit amet eget diam. Curabitur rhoncus lacinia leo at sodales. Etiam finibus porta diam a viverra. Praesent nisi urna, volutpat sit amet odio at, vehicula vehicula leo. In non enim eget nisl luctus commodo. Pellentesque pellentesque at lectus at luctus. Quisque nec felis bibendum, lacinia libero ut, lacinia eros. Integer finibus ultricies nibh sit amet placerat.

-

Nullam scelerisque velit et tortor congue vestibulum a at nisi. Vivamus sodales ut turpis a convallis. In dignissim nibh at luctus sodales. Etiam sit amet rhoncus massa. Phasellus ligula magna, sollicitudin non imperdiet sit amet, volutpat vel magna. Nunc vestibulum tempor lectus, sit amet porta nunc hendrerit in. Curabitur non odio sit amet massa tincidunt facilisis. Integer et luctus nunc, eget euismod leo. Praesent faucibus metus sed purus convallis scelerisque. Fusce viverra lorem et placerat malesuada. In at elit malesuada, ullamcorper risus vitae, sodales dolor. Donec quis elementum lectus. Quisque eu eros at dui imperdiet euismod ut id neque.

-

Notes: Plot

-

Main Plot

-

Tag: Main

-

Suspendisse vulputate malesuada pellentesque. Aenean sollicitudin cursus mi, vitae ultricies felis ullamcorper eu. Duis luctus risus mi, in accumsan velit cursus ut. Vestibulum eleifend leo in magna eleifend fermentum. Proin nec ornare elit. Phasellus nec interdum risus. In a volutpat augue, quis egestas justo. Morbi porta mauris mattis bibendum imperdiet.

-

Mauris ut erat eu lorem malesuada egestas vel vel urna. Maecenas ac semper quam. Maecenas aliquet metus non interdum mattis. Proin consectetur molestie ligula. Aliquam sollicitudin pulvinar urna a pellentesque. Suspendisse ultrices, est mattis scelerisque porta, nisi nisi laoreet nisl, non condimentum quam ante a velit. Proin scelerisque justo augue, nec laoreet ligula egestas at. Etiam enim quam, ultrices non accumsan hendrerit, elementum vel ligula. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Nam efficitur odio libero, in vestibulum arcu aliquam at. Cras non vehicula augue. Integer lobortis, est vitae aliquam facilisis, metus ligula aliquet eros, at porttitor sem tortor eget massa. Aliquam varius scelerisque neque sed gravida. Aenean eleifend lorem id ante elementum sollicitudin. Proin commodo massa a quam volutpat, mollis fermentum turpis efficitur.

-

Notes: World

-

Ancient Europe

-

Tag: Europe

-

Vivamus sodales risus ac accumsan posuere. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nunc vel enim felis. Vestibulum dignissim massa nunc, a auctor magna eleifend et. Proin dignissim sodales erat vitae convallis. Aliquam id tellus dui. Curabitur sollicitudin scelerisque ex sit amet posuere. Nam rutrum felis id rhoncus feugiat. Duis sagittis quam quis purus efficitur, quis rutrum odio iaculis. Maecenas semper ante turpis, at vulputate mi consectetur non. Sed rutrum nibh turpis, quis rhoncus purus ornare quis. Vestibulum at rutrum mauris. Integer dolor nisi, tincidunt eget vehicula ac, ultricies at ligula.

-

Aenean semper turpis quis varius rhoncus. Vivamus ac mi eget felis euismod vulputate. Nam eu tempus velit. Etiam ut est porta, finibus erat sit amet, consectetur felis. Nullam consequat felis ut lacus pharetra, in lobortis urna mollis. Nulla varius eros nec lorem rhoncus, sed venenatis risus ultrices. Phasellus pellentesque laoreet neque, ut ultricies lacus vulputate quis. In malesuada dui sit amet est interdum, eget consectetur mi gravida. Cras vel bibendum purus. Quisque commodo tempor arcu, non lacinia sem blandit eleifend. Quisque at neque gravida, porttitor metus a, suscipit diam. Quisque convallis sodales lacus et condimentum. Donec a suscipit diam. Pellentesque eget cursus neque.

-

Nunc ullamcorper magna quis elit condimentum rhoncus. Aenean dictum pulvinar dolor suscipit interdum. Aliquam elit massa, elementum nec cursus eu, maximus nec ipsum. Donec ullamcorper iaculis dolor eu commodo. Nunc eget tortor quis turpis consectetur varius. Vestibulum nec justo vel tellus venenatis condimentum. Duis auctor iaculis massa. Nunc risus magna, rutrum vitae eros non, tristique mollis enim.

-
- - diff --git a/tests/reference/guiBuild_Tool_Step3_Lorem_Ipsum.md b/tests/reference/guiBuild_Tool_Step3_Lorem_Ipsum.md deleted file mode 100644 index 4c2dee67..00000000 --- a/tests/reference/guiBuild_Tool_Step3_Lorem_Ipsum.md +++ /dev/null @@ -1,183 +0,0 @@ -# Lorem Ipsum - -**By lipsum.com** - -“Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit…” - -“There is no one who loves pain itself, who seeks after it and wants to have it, simply because it is pain…” - -**Comment:** Exctracted from the lipsum.com website. - -Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of “de Finibus Bonorum et Malorum” (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, “Lorem ipsum dolor sit amet..”, comes from a line in section 1.10.32. - -The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from “de Finibus Bonorum et Malorum” by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham. - -## Prologue - -**Synopsis:** Explanation from the lipsum.com website. - -_Lorem Ipsum_ is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. - -# Act One - -“Fusce maximus felis libero” - -## Chapter One: Chapter One - -**Point of View:** Bod -**Plot:** Main -**Locations:** Europe - -**Synopsis:** Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam. - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam. Praesent magna nunc, lacinia sit amet quam eget, aliquet ultrices justo. Morbi ornare enim et lorem rutrum finibus ut eu dolor. Aliquam a orci odio. Ut ultrices sem quis massa placerat, eget mollis nisl cursus. Cras vel sagittis justo. Ut non ultricies leo. Maecenas rutrum velit in est varius, et egestas massa pulvinar. - -### Scene 1.1: Scene One - -**Point of View:** Bod -**Plot:** Main -**Locations:** Europe - -**Synopsis:** Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur. - -Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur. Donec quis ante nunc. Mauris ut leo ipsum. Vestibulum est neque, hendrerit nec neque a, ullamcorper lobortis tellus. Fusce sollicitudin purus quis congue bibendum. Aliquam condimentum ipsum tristique blandit tristique. Donec pulvinar neque ac suscipit malesuada. - -Aliquam ut nisl arcu. Ut ultricies, lorem dignissim rutrum convallis, risus orci tempus lectus, congue feugiat sem lectus vitae odio. Duis sit amet justo finibus, hendrerit nulla at, ullamcorper enim. Praesent vel tellus sit amet tellus vulputate bibendum. Morbi eleifend sagittis sem, ac volutpat ante congue non. In hac habitasse platea dictumst. Morbi lobortis fermentum elit, dignissim sagittis ligula volutpat lacinia. Vestibulum eu interdum odio. Integer ac purus commodo metus congue tempor non at urna. Sed eget tortor vel quam viverra egestas. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Donec non convallis mauris, ac feugiat ex. - -#### 1.1.1: Scene One, Section Two - -Integer vel libero ipsum. Donec varius aliquam libero, sit amet commodo urna hendrerit non. Nullam quis erat mollis nunc viverra volutpat tincidunt in odio. Nam vitae quam sem. Aliquam suscipit nulla non lorem pharetra semper. Ut suscipit erat eu ligula accumsan ultrices. Phasellus nisl tellus, placerat sed laoreet id, consectetur nec dolor. Sed fringilla ipsum id dapibus posuere. Aenean finibus pharetra tincidunt. Ut molestie malesuada nulla, id posuere lorem tincidunt eu. Aliquam tempor eros a est vulputate, scelerisque pulvinar ipsum fermentum. In hac habitasse platea dictumst. - -Curabitur congue, justo quis interdum fermentum, tellus nulla imperdiet sapien, eu interdum enim tellus condimentum metus. Vivamus nunc velit, dignissim ut ultrices sit amet, ultricies quis enim. Donec ut vestibulum neque. Vivamus semper neque id ex ullamcorper varius. Fusce mattis nibh viverra lorem sagittis, et tempor arcu congue. Suspendisse sit amet felis sed urna facilisis mattis eget vitae arcu. Proin eu magna hendrerit, tristique sem maximus, placerat diam. Nulla tristique sed velit sit amet varius. Etiam vel ornare magna, in vulputate arcu. Cras velit orci, tincidunt sed volutpat cursus, bibendum vel sem. Nunc vulputate pharetra tortor, ac consectetur neque tincidunt sit amet. Nulla ornare mi sed mi dignissim ultricies. Ut tincidunt bibendum mauris, sed elementum ex vulputate vel. Mauris fermentum, felis nec vehicula congue, felis lorem facilisis erat, a dictum dolor augue vitae quam. Maecenas rutrum tortor nec consequat eleifend. - -### Scene 1.2: Scene Two - -**Point of View:** Bod -**Plot:** Main -**Locations:** Europe - -**Synopsis:** Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci. - -Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci. Vestibulum facilisis bibendum aliquam. Aliquam posuere, turpis ac bibendum varius, sem tellus venenatis risus, in elementum massa enim ac lorem. Integer in sem ac diam blandit ultricies ut in nulla. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam sit amet erat est. Curabitur vitae cursus justo, sit amet placerat dolor. Vivamus eu felis hendrerit, tincidunt massa rutrum, maximus arcu. Pellentesque commodo justo odio, vel rutrum nulla tincidunt eu. Integer non neque condimentum, convallis diam non, varius ligula. Aliquam eget sapien mauris. Aenean pharetra nunc nisi, vel maximus ante tristique sit amet. Aliquam risus metus, interdum non odio eu, consectetur lacinia sapien. - -Proin vitae gravida nisl. Integer viverra orci turpis, sit amet pretium ligula facilisis consequat. Nulla interdum commodo metus, mollis consequat dui tincidunt et. Proin consequat bibendum justo id commodo. Fusce fermentum nunc turpis, eu vestibulum risus feugiat ut. Sed scelerisque vel ligula ut interdum. Suspendisse ac blandit ligula, sagittis fringilla dolor. In tincidunt convallis diam et ornare. Aenean id dignissim est, ut rhoncus quam. Donec vitae nisl velit. In convallis nibh ut augue dignissim, eu elementum quam cursus. Phasellus in lectus lorem. Curabitur in pellentesque nisi, at gravida sapien. Sed cursus justo volutpat lacus placerat, sit amet dignissim turpis commodo. Aliquam vitae orci eget nulla posuere condimentum in ut felis. - -Nulla accumsan ante in pulvinar efficitur. Nulla non velit quis urna hendrerit bibendum. Suspendisse ultrices ante eu justo malesuada, sed fermentum enim rutrum. Nunc fermentum pharetra felis, vitae sollicitudin quam rutrum porta. Aliquam fringilla velit a mi laoreet, et luctus est rutrum. In gravida non ipsum sit amet tempus. Curabitur et eleifend purus. Nulla facilisi. - -#### 1.2.1: Scene Two, Section Two - -Suspendisse potenti. Fusce tempus lorem nec laoreet suscipit. Fusce vulputate nisl ac diam tincidunt, nec malesuada quam pellentesque. Maecenas congue, tellus quis commodo rutrum, magna leo egestas arcu, quis suscipit ex risus id ligula. Suspendisse potenti. Morbi blandit lacus vitae laoreet vulputate. Donec vitae tellus eleifend, lobortis eros eu, tincidunt enim. Nullam et ullamcorper nisi. Vivamus tellus ex, lobortis quis rutrum ut, dapibus sit amet turpis. Phasellus pellentesque metus diam, commodo tristique ante commodo ac. Ut mollis ipsum nec diam blandit sollicitudin. Duis bibendum lacus nec commodo dapibus. Sed condimentum luctus ante, id ultricies urna varius nec. Nam convallis magna nec bibendum ultrices. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed auctor pharetra quam, vitae porta ex bibendum eu. - -Vivamus ut venenatis lectus. Phasellus nec elit id sem dictum ornare. Quisque feugiat, diam eget sagittis ultricies, orci turpis efficitur nisi, et fringilla justo odio nec nibh. In hac habitasse platea dictumst. Sed tempus bibendum feugiat. Etiam luctus mauris arcu, non interdum ipsum ultrices id. Vivamus blandit urna sit amet scelerisque vulputate. Quisque in metus eget massa rutrum dictum sit amet sed nulla. Vivamus vel efficitur dolor. - -Ut et consequat enim, quis ornare nibh. In lectus neque, mollis et suscipit et, vestibulum vitae augue. Praesent id ante sit amet odio venenatis placerat a at erat. Sed sed metus sed nisi dictum varius. Integer tincidunt fermentum purus ac porta. Fusce porttitor non risus eget tristique. Donec augue nunc, maximus at fermentum vel, varius et neque. Ut sed consectetur mauris. Quisque ipsum enim, porttitor vitae imperdiet sit amet, tempor et mauris. Aliquam malesuada tincidunt lectus quis blandit. Sed commodo orci felis, quis ultrices tellus facilisis sed. Nunc vel varius est. Duis ullamcorper eu metus in pulvinar. Morbi at sapien dictum, rutrum mauris eget, interdum tellus. - -## Why do we use it? - -**Comment:** Exctracted from the lipsum.com website. - - It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout. - - The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, as opposed to using 'Content here, content here', making it look like readable English. - - Many desktop publishing packages and web page editors now use Lorem Ipsum as their default model text, and a search for 'lorem ipsum' will uncover many web sites still in their infancy. Various versions have evolved over the years, sometimes by accident, sometimes on purpose (injected humour and the like). - -## Chapter Two: Chapter Two - -**Point of View:** Bod -**Plot:** Main -**Locations:** Europe - -**Synopsis:** Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue. - -Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue. Vestibulum pharetra fermentum leo, sed faucibus eros placerat quis. In hac habitasse platea dictumst. Donec metus massa, rutrum quis consequat et, tincidunt ac felis. Duis mollis metus ac nunc tincidunt blandit. Ut aliquet velit eu odio pharetra condimentum. Integer rutrum lacus orci, id venenatis libero accumsan at. - -### Scene 2.1: Scene Three - -**Point of View:** Bod -**Plot:** Main -**Locations:** Europe - -**Synopsis:** Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. - -Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Aenean tincidunt lacus vitae nibh elementum eleifend. Sed rutrum condimentum sem quis blandit. Duis imperdiet libero metus, quis convallis quam faucibus a. Nulla ligula est, semper quis sollicitudin et, pretium id justo. Curabitur pharetra risus eget consectetur commodo. Duis mattis arcu non est condimentum, id venenatis risus volutpat. Pellentesque aliquet mauris non mauris porttitor ultrices. Phasellus ut vestibulum mi. Suspendisse malesuada metus lorem, a malesuada orci rhoncus a. Praesent euismod convallis ante, lacinia tincidunt ex egestas id. Praesent sit amet efficitur sapien. Morbi tincidunt volutpat nunc sed dictum. Aliquam ultrices metus id fermentum lobortis. - -Pellentesque id sagittis dui. Praesent ut nisi sit amet libero euismod ornare. Vestibulum vehicula, lorem eget aliquet imperdiet, eros nulla iaculis mi, vel bibendum est dui sed orci. Nullam vitae lorem rutrum, euismod lacus id, ullamcorper lectus. Duis nec commodo mi, a fringilla diam. Vestibulum molestie nibh tristique, viverra augue non, aliquet metus. Phasellus a tellus ac nisl tempor aliquet. Nulla vitae sapien rutrum augue ornare ultrices a quis nisi. Sed pulvinar tincidunt ex. Fusce vel sem vitae ante pellentesque lobortis. - -Maecenas ullamcorper lacus nec turpis finibus aliquet eget rutrum augue. Integer lorem erat, faucibus non lacus lacinia, pulvinar egestas felis. Proin rutrum nunc eget nulla varius, id blandit mauris tincidunt. Donec sit amet ullamcorper nisi, ut efficitur mi. Aliquam aliquet, nulla eget rhoncus tristique, justo lorem consectetur dui, id ornare leo odio sed tellus. Curabitur interdum velit a turpis condimentum venenatis. Nunc rhoncus sem ac augue auctor, nec malesuada ex fringilla. Vestibulum egestas diam sed leo consectetur vulputate quis eget enim. Nam tincidunt metus sit amet maximus ullamcorper. Sed placerat velit vitae massa efficitur viverra. Etiam eleifend dignissim ante, sed luctus nisl tristique a. In vestibulum pharetra dolor in molestie. Vivamus auctor massa ac magna imperdiet, sit amet iaculis turpis finibus. - -Aenean dapibus vulputate purus, sit amet tempor nunc suscipit consequat. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Mauris auctor congue eros, non pellentesque neque dapibus ac. Vestibulum non leo nec urna lacinia eleifend quis et diam. Praesent eu nisi magna. Nulla at magna massa. Suspendisse porta varius scelerisque. Duis at auctor dolor, non dapibus urna. Nunc venenatis feugiat magna non molestie. Aliquam non ornare ex. Quisque eu ultrices velit, quis pellentesque eros. Phasellus eleifend, elit id imperdiet aliquam, nulla quam molestie turpis, at egestas odio ante et tortor. Suspendisse fringilla condimentum justo, at aliquet odio aliquam ac. - -### Scene 2.2: Scene Four - -**Point of View:** Bod -**Plot:** Main -**Locations:** Europe - -**Synopsis:** Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo. - -Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo. Nullam viverra dui et auctor pretium. Ut ullamcorper velit urna, sed imperdiet massa convallis a. Suspendisse efficitur, ipsum nec cursus pulvinar, eros urna posuere diam, nec elementum mi felis vitae sapien. - -Duis efficitur metus pulvinar, molestie magna eget, feugiat dui. Fusce convallis vehicula ipsum convallis blandit. Duis eros risus, malesuada eu imperdiet in, hendrerit ac metus. Vestibulum id justo gravida, dignissim nibh non, iaculis diam. Fusce accumsan est ut massa porta ultricies. Nulla vitae justo in tortor laoreet mollis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin eu libero justo. Vivamus aliquet placerat est, et auctor eros posuere venenatis. Nunc quam diam, tincidunt ac aliquet in, fermentum sit amet lectus. Proin commodo tincidunt blandit. Quisque erat arcu, semper nec dui non, consectetur gravida ipsum. Nullam pretium consectetur elit at condimentum. - -Etiam sagittis, erat vitae accumsan tempor, neque augue scelerisque nulla, ut ultrices justo urna sit amet augue. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean at pulvinar tortor. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Cras vel porta quam. Nullam eu mauris mollis, vehicula justo vel, placerat sapien. Phasellus viverra elit et vestibulum pharetra. Vestibulum commodo fermentum leo, eu porta nisi aliquam eget. Nulla tempus porttitor nisi nec mollis. Nam non mollis turpis. Nam finibus leo a bibendum tincidunt. Donec commodo velit magna, ac semper sapien mattis id. Proin sem velit, lobortis quis ultricies id, pharetra et lectus. Vestibulum condimentum neque vitae mi dapibus mollis. Mauris luctus vel sapien vitae hendrerit. - -Aenean vestibulum magna placerat fermentum tempus. Nam auctor condimentum nunc, in elementum quam ornare a. Etiam in ipsum elit. Proin pharetra, dolor sollicitudin pellentesque congue, lorem dolor ultricies magna, non iaculis risus nisl dictum diam. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Vivamus vel euismod nibh, et lobortis dolor. Maecenas dui odio, gravida nec molestie ut, feugiat ut arcu. Pellentesque risus sapien, gravida a convallis quis, ullamcorper porttitor sapien. - -Donec ipsum eros, vestibulum sit amet cursus eget, iaculis quis dolor. Pellentesque magna augue, tristique dapibus mi vitae, molestie venenatis enim. Nam malesuada, turpis volutpat rhoncus ullamcorper, justo est eleifend orci, ut luctus risus ex rutrum arcu. Sed mi elit, feugiat rhoncus ornare sed, porta id leo. Pellentesque feugiat nulla tincidunt erat suscipit, eu congue lacus hendrerit. Morbi pulvinar enim sed consequat auctor. Ut eleifend enim sem, vitae euismod ex ultricies sit amet. Curabitur eu efficitur nisi, suscipit finibus sapien. In sodales blandit erat, vestibulum pulvinar ante volutpat nec. Vivamus dictum non libero at molestie. Donec sit amet neque in ante convallis pretium. Nunc vel iaculis dui. - -Phasellus eu nunc ut nunc faucibus laoreet. Aliquam at magna risus. Praesent lobortis, risus finibus semper varius, magna purus vestibulum eros, at pulvinar sapien enim a ex. In scelerisque malesuada ex, sit amet egestas neque condimentum sed. Praesent vulputate efficitur massa. Cras at accumsan ligula. In elementum lectus eget blandit dictum. Nam vitae libero ut justo eleifend rutrum ac nec arcu. Aliquam sodales in quam congue vestibulum. Aliquam in accumsan sapien. Quisque lobortis nisl nisi, vitae bibendum turpis efficitur sed. Vestibulum tempor nulla eget nisi convallis, blandit sagittis ipsum convallis. Donec odio nibh, ultrices quis odio in, mollis euismod libero. - -### Scene 2.3: Scene Five - -**Point of View:** Bod -**Plot:** Main -**Locations:** Europe - -**Synopsis:** Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. - -Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. In sed felis auctor, rhoncus dui ac, consequat dolor. Integer volutpat libero sed nisl aliquet varius. Suspendisse et lorem sapien. Proin id ultrices nibh, ac suscipit diam. Suspendisse placerat varius porttitor. Curabitur elementum sed enim ultrices imperdiet. - -In ut lobortis lacus, nec luctus arcu. Vivamus condimentum sapien a ipsum malesuada sodales. Donec et vestibulum risus. Integer dictum euismod eros id tincidunt. Aliquam sagittis leo vitae consequat fermentum. Donec maximus ex eu ex iaculis porta. Praesent pharetra lacinia risus, et eleifend diam commodo non. Sed feugiat ipsum ut orci sagittis, quis faucibus lectus blandit. Sed tellus quam, gravida vitae laoreet quis, tempus lobortis dui. Vivamus semper accumsan ullamcorper. Praesent tempus pretium eros, non elementum risus. Pellentesque odio quam, auctor quis ex non, vulputate egestas dolor. Nunc luctus enim ut justo sodales consectetur. Sed aliquet a mauris vel posuere. - -Donec luctus lectus efficitur, blandit nisi vitae, dignissim tellus. Pellentesque euismod pharetra augue gravida hendrerit. Quisque nisi mi, mattis ac nisi non, maximus malesuada ante. Nulla lobortis, diam eu ornare ornare, tellus enim feugiat arcu, non vestibulum tortor nunc eu justo. Integer blandit felis justo, eu semper est scelerisque vel. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nam ultricies, nisi vel elementum commodo, nisl dolor tincidunt magna, sed varius est nunc at lectus. Aliquam dolor tortor, sodales placerat ultricies quis, sodales quis sapien. Duis ullamcorper sollicitudin risus at mattis. Integer consequat et nunc at condimentum. Pellentesque cursus congue augue, non suscipit lectus sodales ut. Nam a mi bibendum, blandit nisl eu, accumsan nunc. Aliquam a ex mauris. Sed nec sem quis arcu dignissim tempus eget et turpis. Ut sed ex nec ipsum ultrices lobortis. - -Pellentesque rhoncus pharetra eros, non mollis nisi pretium non. Mauris accumsan quis odio quis euismod. Maecenas ultrices, augue et aliquam tincidunt, erat tellus ornare ligula, quis ultrices turpis nibh vel justo. Fusce gravida odio tellus. In a congue diam. Mauris consequat ex id leo lacinia dictum. Fusce id sem sodales, ultrices sapien ac, convallis orci. Donec gravida nunc sit amet nisi hendrerit, sed porta enim aliquam. In hac habitasse platea dictumst. Cras a orci felis. Curabitur non felis nec urna maximus auctor ut ut nisi. Curabitur at turpis eleifend, blandit eros at, molestie odio. Phasellus euismod neque augue. - -Integer egestas maximus leo eu facilisis. Nunc rhoncus dignissim lectus eu lacinia. Praesent lacinia urna porttitor aliquam condimentum. Nulla eu eros dictum, dictum nunc vitae, sagittis nibh. Integer ante neque, consequat nec sollicitudin id, consectetur vitae dolor. Nullam volutpat sem orci, quis viverra magna auctor a. Suspendisse potenti. Maecenas commodo sed neque pellentesque vehicula. Sed luctus nisl risus, elementum semper purus interdum vel. Ut pulvinar, massa sit amet venenatis placerat, nunc lacus hendrerit odio, non aliquet nunc risus eu lectus. Maecenas feugiat semper ligula, id lobortis sem porta eu. Integer posuere elit magna, at mollis eros bibendum et. Ut imperdiet purus vel nulla aliquam maximus. Morbi sodales purus tellus, a rhoncus sem rutrum sit amet. Quisque risus sem, laoreet nec convallis nec, rutrum vitae justo. - -# Notes: Characters - -# Nobody Owens - -**Tag:** Bod -**Plot:** Main - -Pellentesque nec erat ut nulla posuere commodo. Curabitur nisi augue, imperdiet et porta imperdiet, efficitur id leo. Cras finibus arcu at nibh commodo congue. Proin suscipit placerat condimentum. Aenean ante enim, cursus id lorem a, blandit venenatis nibh. Maecenas suscipit porta elit, sit amet porta felis porttitor eu. Sed a dui nibh. Phasellus sed faucibus dui. Pellentesque felis nulla, ultrices non efficitur quis, rutrum id mi. Mauris tempus auctor nisl, in bibendum enim pellentesque sit amet. Proin nunc lacus, imperdiet nec posuere ac, interdum non lectus. - -Suspendisse faucibus est auctor orci mollis luctus. Praesent quis sodales neque. Interdum et malesuada fames ac ante ipsum primis in faucibus. Donec sodales rutrum mattis. In in sem ornare, consequat nulla ac, convallis arcu. Duis ac metus id felis commodo commodo sit amet eget diam. Curabitur rhoncus lacinia leo at sodales. Etiam finibus porta diam a viverra. Praesent nisi urna, volutpat sit amet odio at, vehicula vehicula leo. In non enim eget nisl luctus commodo. Pellentesque pellentesque at lectus at luctus. Quisque nec felis bibendum, lacinia libero ut, lacinia eros. Integer finibus ultricies nibh sit amet placerat. - -Nullam scelerisque velit et tortor congue vestibulum a at nisi. Vivamus sodales ut turpis a convallis. In dignissim nibh at luctus sodales. Etiam sit amet rhoncus massa. Phasellus ligula magna, sollicitudin non imperdiet sit amet, volutpat vel magna. Nunc vestibulum tempor lectus, sit amet porta nunc hendrerit in. Curabitur non odio sit amet massa tincidunt facilisis. Integer et luctus nunc, eget euismod leo. Praesent faucibus metus sed purus convallis scelerisque. Fusce viverra lorem et placerat malesuada. In at elit malesuada, ullamcorper risus vitae, sodales dolor. Donec quis elementum lectus. Quisque eu eros at dui imperdiet euismod ut id neque. - -# Notes: Plot - -# Main Plot - -**Tag:** Main - -Suspendisse vulputate malesuada pellentesque. Aenean sollicitudin cursus mi, vitae ultricies felis ullamcorper eu. Duis luctus risus mi, in accumsan velit cursus ut. Vestibulum eleifend leo in magna eleifend fermentum. Proin nec ornare elit. Phasellus nec interdum risus. In a volutpat augue, quis egestas justo. Morbi porta mauris mattis bibendum imperdiet. - -Mauris ut erat eu lorem malesuada egestas vel vel urna. Maecenas ac semper quam. Maecenas aliquet metus non interdum mattis. Proin consectetur molestie ligula. Aliquam sollicitudin pulvinar urna a pellentesque. Suspendisse ultrices, est mattis scelerisque porta, nisi nisi laoreet nisl, non condimentum quam ante a velit. Proin scelerisque justo augue, nec laoreet ligula egestas at. Etiam enim quam, ultrices non accumsan hendrerit, elementum vel ligula. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Nam efficitur odio libero, in vestibulum arcu aliquam at. Cras non vehicula augue. Integer lobortis, est vitae aliquam facilisis, metus ligula aliquet eros, at porttitor sem tortor eget massa. Aliquam varius scelerisque neque sed gravida. Aenean eleifend lorem id ante elementum sollicitudin. Proin commodo massa a quam volutpat, mollis fermentum turpis efficitur. - -# Notes: World - -# Ancient Europe - -**Tag:** Europe - -Vivamus sodales risus ac accumsan posuere. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nunc vel enim felis. Vestibulum dignissim massa nunc, a auctor magna eleifend et. Proin dignissim sodales erat vitae convallis. Aliquam id tellus dui. Curabitur sollicitudin scelerisque ex sit amet posuere. Nam rutrum felis id rhoncus feugiat. Duis sagittis quam quis purus efficitur, quis rutrum odio iaculis. Maecenas semper ante turpis, at vulputate mi consectetur non. Sed rutrum nibh turpis, quis rhoncus purus ornare quis. Vestibulum at rutrum mauris. Integer dolor nisi, tincidunt eget vehicula ac, ultricies at ligula. - -Aenean semper turpis quis varius rhoncus. Vivamus ac mi eget felis euismod vulputate. Nam eu tempus velit. Etiam ut est porta, finibus erat sit amet, consectetur felis. Nullam consequat felis ut lacus pharetra, in lobortis urna mollis. Nulla varius eros nec lorem rhoncus, sed venenatis risus ultrices. Phasellus pellentesque laoreet neque, ut ultricies lacus vulputate quis. In malesuada dui sit amet est interdum, eget consectetur mi gravida. Cras vel bibendum purus. Quisque commodo tempor arcu, non lacinia sem blandit eleifend. Quisque at neque gravida, porttitor metus a, suscipit diam. Quisque convallis sodales lacus et condimentum. Donec a suscipit diam. Pellentesque eget cursus neque. - -Nunc ullamcorper magna quis elit condimentum rhoncus. Aenean dictum pulvinar dolor suscipit interdum. Aliquam elit massa, elementum nec cursus eu, maximus nec ipsum. Donec ullamcorper iaculis dolor eu commodo. Nunc eget tortor quis turpis consectetur varius. Vestibulum nec justo vel tellus venenatis condimentum. Duis auctor iaculis massa. Nunc risus magna, rutrum vitae eros non, tristique mollis enim. - diff --git a/tests/reference/guiBuild_Tool_Step3_Lorem_Ipsum.nwd b/tests/reference/guiBuild_Tool_Step3_Lorem_Ipsum.nwd deleted file mode 100644 index f0e030c7..00000000 --- a/tests/reference/guiBuild_Tool_Step3_Lorem_Ipsum.nwd +++ /dev/null @@ -1,184 +0,0 @@ -#! Lorem Ipsum - -**By lipsum.com** - -“Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit…” - -“There is no one who loves pain itself, who seeks after it and wants to have it, simply because it is pain…” - - -% Exctracted from the lipsum.com website. - -Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of “de Finibus Bonorum et Malorum” (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, “Lorem ipsum dolor sit amet..”, comes from a line in section 1.10.32. - -The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from “de Finibus Bonorum et Malorum” by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham. - -##! Prologue - -% Synopsis:Explanation from the lipsum.com website. - -_Lorem Ipsum_ is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. - -# Act One - -“Fusce maximus felis libero” - -## Chapter One - -@pov: Bod -@plot: Main -@location: Europe - -% Synopsis: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam. - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam. Praesent magna nunc, lacinia sit amet quam eget, aliquet ultrices justo. Morbi ornare enim et lorem rutrum finibus ut eu dolor. Aliquam a orci odio. Ut ultrices sem quis massa placerat, eget mollis nisl cursus. Cras vel sagittis justo. Ut non ultricies leo. Maecenas rutrum velit in est varius, et egestas massa pulvinar. - -### Scene One - -@pov: Bod -@plot: Main -@location: Europe - -% Synopsis: Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur. - -Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur. Donec quis ante nunc. Mauris ut leo ipsum. Vestibulum est neque, hendrerit nec neque a, ullamcorper lobortis tellus. Fusce sollicitudin purus quis congue bibendum. Aliquam condimentum ipsum tristique blandit tristique. Donec pulvinar neque ac suscipit malesuada. - -Aliquam ut nisl arcu. Ut ultricies, lorem dignissim rutrum convallis, risus orci tempus lectus, congue feugiat sem lectus vitae odio. Duis sit amet justo finibus, hendrerit nulla at, ullamcorper enim. Praesent vel tellus sit amet tellus vulputate bibendum. Morbi eleifend sagittis sem, ac volutpat ante congue non. In hac habitasse platea dictumst. Morbi lobortis fermentum elit, dignissim sagittis ligula volutpat lacinia. Vestibulum eu interdum odio. Integer ac purus commodo metus congue tempor non at urna. Sed eget tortor vel quam viverra egestas. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Donec non convallis mauris, ac feugiat ex. - -#### Scene One, Section Two - -Integer vel libero ipsum. Donec varius aliquam libero, sit amet commodo urna hendrerit non. Nullam quis erat mollis nunc viverra volutpat tincidunt in odio. Nam vitae quam sem. Aliquam suscipit nulla non lorem pharetra semper. Ut suscipit erat eu ligula accumsan ultrices. Phasellus nisl tellus, placerat sed laoreet id, consectetur nec dolor. Sed fringilla ipsum id dapibus posuere. Aenean finibus pharetra tincidunt. Ut molestie malesuada nulla, id posuere lorem tincidunt eu. Aliquam tempor eros a est vulputate, scelerisque pulvinar ipsum fermentum. In hac habitasse platea dictumst. - -Curabitur congue, justo quis interdum fermentum, tellus nulla imperdiet sapien, eu interdum enim tellus condimentum metus. Vivamus nunc velit, dignissim ut ultrices sit amet, ultricies quis enim. Donec ut vestibulum neque. Vivamus semper neque id ex ullamcorper varius. Fusce mattis nibh viverra lorem sagittis, et tempor arcu congue. Suspendisse sit amet felis sed urna facilisis mattis eget vitae arcu. Proin eu magna hendrerit, tristique sem maximus, placerat diam. Nulla tristique sed velit sit amet varius. Etiam vel ornare magna, in vulputate arcu. Cras velit orci, tincidunt sed volutpat cursus, bibendum vel sem. Nunc vulputate pharetra tortor, ac consectetur neque tincidunt sit amet. Nulla ornare mi sed mi dignissim ultricies. Ut tincidunt bibendum mauris, sed elementum ex vulputate vel. Mauris fermentum, felis nec vehicula congue, felis lorem facilisis erat, a dictum dolor augue vitae quam. Maecenas rutrum tortor nec consequat eleifend. - -### Scene Two - -@pov: Bod -@plot: Main -@location: Europe - -% Synopsis: Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci. - -Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci. Vestibulum facilisis bibendum aliquam. Aliquam posuere, turpis ac bibendum varius, sem tellus venenatis risus, in elementum massa enim ac lorem. Integer in sem ac diam blandit ultricies ut in nulla. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam sit amet erat est. Curabitur vitae cursus justo, sit amet placerat dolor. Vivamus eu felis hendrerit, tincidunt massa rutrum, maximus arcu. Pellentesque commodo justo odio, vel rutrum nulla tincidunt eu. Integer non neque condimentum, convallis diam non, varius ligula. Aliquam eget sapien mauris. Aenean pharetra nunc nisi, vel maximus ante tristique sit amet. Aliquam risus metus, interdum non odio eu, consectetur lacinia sapien. - -Proin vitae gravida nisl. Integer viverra orci turpis, sit amet pretium ligula facilisis consequat. Nulla interdum commodo metus, mollis consequat dui tincidunt et. Proin consequat bibendum justo id commodo. Fusce fermentum nunc turpis, eu vestibulum risus feugiat ut. Sed scelerisque vel ligula ut interdum. Suspendisse ac blandit ligula, sagittis fringilla dolor. In tincidunt convallis diam et ornare. Aenean id dignissim est, ut rhoncus quam. Donec vitae nisl velit. In convallis nibh ut augue dignissim, eu elementum quam cursus. Phasellus in lectus lorem. Curabitur in pellentesque nisi, at gravida sapien. Sed cursus justo volutpat lacus placerat, sit amet dignissim turpis commodo. Aliquam vitae orci eget nulla posuere condimentum in ut felis. - -Nulla accumsan ante in pulvinar efficitur. Nulla non velit quis urna hendrerit bibendum. Suspendisse ultrices ante eu justo malesuada, sed fermentum enim rutrum. Nunc fermentum pharetra felis, vitae sollicitudin quam rutrum porta. Aliquam fringilla velit a mi laoreet, et luctus est rutrum. In gravida non ipsum sit amet tempus. Curabitur et eleifend purus. Nulla facilisi. - -#### Scene Two, Section Two - -Suspendisse potenti. Fusce tempus lorem nec laoreet suscipit. Fusce vulputate nisl ac diam tincidunt, nec malesuada quam pellentesque. Maecenas congue, tellus quis commodo rutrum, magna leo egestas arcu, quis suscipit ex risus id ligula. Suspendisse potenti. Morbi blandit lacus vitae laoreet vulputate. Donec vitae tellus eleifend, lobortis eros eu, tincidunt enim. Nullam et ullamcorper nisi. Vivamus tellus ex, lobortis quis rutrum ut, dapibus sit amet turpis. Phasellus pellentesque metus diam, commodo tristique ante commodo ac. Ut mollis ipsum nec diam blandit sollicitudin. Duis bibendum lacus nec commodo dapibus. Sed condimentum luctus ante, id ultricies urna varius nec. Nam convallis magna nec bibendum ultrices. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed auctor pharetra quam, vitae porta ex bibendum eu. - -Vivamus ut venenatis lectus. Phasellus nec elit id sem dictum ornare. Quisque feugiat, diam eget sagittis ultricies, orci turpis efficitur nisi, et fringilla justo odio nec nibh. In hac habitasse platea dictumst. Sed tempus bibendum feugiat. Etiam luctus mauris arcu, non interdum ipsum ultrices id. Vivamus blandit urna sit amet scelerisque vulputate. Quisque in metus eget massa rutrum dictum sit amet sed nulla. Vivamus vel efficitur dolor. - -Ut et consequat enim, quis ornare nibh. In lectus neque, mollis et suscipit et, vestibulum vitae augue. Praesent id ante sit amet odio venenatis placerat a at erat. Sed sed metus sed nisi dictum varius. Integer tincidunt fermentum purus ac porta. Fusce porttitor non risus eget tristique. Donec augue nunc, maximus at fermentum vel, varius et neque. Ut sed consectetur mauris. Quisque ipsum enim, porttitor vitae imperdiet sit amet, tempor et mauris. Aliquam malesuada tincidunt lectus quis blandit. Sed commodo orci felis, quis ultrices tellus facilisis sed. Nunc vel varius est. Duis ullamcorper eu metus in pulvinar. Morbi at sapien dictum, rutrum mauris eget, interdum tellus. - -##! Why do we use it? - -% Exctracted from the lipsum.com website. - - It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout. - - The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, as opposed to using 'Content here, content here', making it look like readable English. - - Many desktop publishing packages and web page editors now use Lorem Ipsum as their default model text, and a search for 'lorem ipsum' will uncover many web sites still in their infancy. Various versions have evolved over the years, sometimes by accident, sometimes on purpose (injected humour and the like). - -## Chapter Two - -@pov: Bod -@plot: Main -@location: Europe - -% Synopsis: Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue. - -Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue. Vestibulum pharetra fermentum leo, sed faucibus eros placerat quis. In hac habitasse platea dictumst. Donec metus massa, rutrum quis consequat et, tincidunt ac felis. Duis mollis metus ac nunc tincidunt blandit. Ut aliquet velit eu odio pharetra condimentum. Integer rutrum lacus orci, id venenatis libero accumsan at. - -### Scene Three - -@pov: Bod -@plot: Main -@location: Europe - -% Synopsis: Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. - -Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Aenean tincidunt lacus vitae nibh elementum eleifend. Sed rutrum condimentum sem quis blandit. Duis imperdiet libero metus, quis convallis quam faucibus a. Nulla ligula est, semper quis sollicitudin et, pretium id justo. Curabitur pharetra risus eget consectetur commodo. Duis mattis arcu non est condimentum, id venenatis risus volutpat. Pellentesque aliquet mauris non mauris porttitor ultrices. Phasellus ut vestibulum mi. Suspendisse malesuada metus lorem, a malesuada orci rhoncus a. Praesent euismod convallis ante, lacinia tincidunt ex egestas id. Praesent sit amet efficitur sapien. Morbi tincidunt volutpat nunc sed dictum. Aliquam ultrices metus id fermentum lobortis. - -Pellentesque id sagittis dui. Praesent ut nisi sit amet libero euismod ornare. Vestibulum vehicula, lorem eget aliquet imperdiet, eros nulla iaculis mi, vel bibendum est dui sed orci. Nullam vitae lorem rutrum, euismod lacus id, ullamcorper lectus. Duis nec commodo mi, a fringilla diam. Vestibulum molestie nibh tristique, viverra augue non, aliquet metus. Phasellus a tellus ac nisl tempor aliquet. Nulla vitae sapien rutrum augue ornare ultrices a quis nisi. Sed pulvinar tincidunt ex. Fusce vel sem vitae ante pellentesque lobortis. - -Maecenas ullamcorper lacus nec turpis finibus aliquet eget rutrum augue. Integer lorem erat, faucibus non lacus lacinia, pulvinar egestas felis. Proin rutrum nunc eget nulla varius, id blandit mauris tincidunt. Donec sit amet ullamcorper nisi, ut efficitur mi. Aliquam aliquet, nulla eget rhoncus tristique, justo lorem consectetur dui, id ornare leo odio sed tellus. Curabitur interdum velit a turpis condimentum venenatis. Nunc rhoncus sem ac augue auctor, nec malesuada ex fringilla. Vestibulum egestas diam sed leo consectetur vulputate quis eget enim. Nam tincidunt metus sit amet maximus ullamcorper. Sed placerat velit vitae massa efficitur viverra. Etiam eleifend dignissim ante, sed luctus nisl tristique a. In vestibulum pharetra dolor in molestie. Vivamus auctor massa ac magna imperdiet, sit amet iaculis turpis finibus. - -Aenean dapibus vulputate purus, sit amet tempor nunc suscipit consequat. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Mauris auctor congue eros, non pellentesque neque dapibus ac. Vestibulum non leo nec urna lacinia eleifend quis et diam. Praesent eu nisi magna. Nulla at magna massa. Suspendisse porta varius scelerisque. Duis at auctor dolor, non dapibus urna. Nunc venenatis feugiat magna non molestie. Aliquam non ornare ex. Quisque eu ultrices velit, quis pellentesque eros. Phasellus eleifend, elit id imperdiet aliquam, nulla quam molestie turpis, at egestas odio ante et tortor. Suspendisse fringilla condimentum justo, at aliquet odio aliquam ac. - -### Scene Four - -@pov: Bod -@plot: Main -@location: Europe - -% Synopsis: Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo. - -Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo. Nullam viverra dui et auctor pretium. Ut ullamcorper velit urna, sed imperdiet massa convallis a. Suspendisse efficitur, ipsum nec cursus pulvinar, eros urna posuere diam, nec elementum mi felis vitae sapien. - -Duis efficitur metus pulvinar, molestie magna eget, feugiat dui. Fusce convallis vehicula ipsum convallis blandit. Duis eros risus, malesuada eu imperdiet in, hendrerit ac metus. Vestibulum id justo gravida, dignissim nibh non, iaculis diam. Fusce accumsan est ut massa porta ultricies. Nulla vitae justo in tortor laoreet mollis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin eu libero justo. Vivamus aliquet placerat est, et auctor eros posuere venenatis. Nunc quam diam, tincidunt ac aliquet in, fermentum sit amet lectus. Proin commodo tincidunt blandit. Quisque erat arcu, semper nec dui non, consectetur gravida ipsum. Nullam pretium consectetur elit at condimentum. - -Etiam sagittis, erat vitae accumsan tempor, neque augue scelerisque nulla, ut ultrices justo urna sit amet augue. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aenean at pulvinar tortor. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Cras vel porta quam. Nullam eu mauris mollis, vehicula justo vel, placerat sapien. Phasellus viverra elit et vestibulum pharetra. Vestibulum commodo fermentum leo, eu porta nisi aliquam eget. Nulla tempus porttitor nisi nec mollis. Nam non mollis turpis. Nam finibus leo a bibendum tincidunt. Donec commodo velit magna, ac semper sapien mattis id. Proin sem velit, lobortis quis ultricies id, pharetra et lectus. Vestibulum condimentum neque vitae mi dapibus mollis. Mauris luctus vel sapien vitae hendrerit. - -Aenean vestibulum magna placerat fermentum tempus. Nam auctor condimentum nunc, in elementum quam ornare a. Etiam in ipsum elit. Proin pharetra, dolor sollicitudin pellentesque congue, lorem dolor ultricies magna, non iaculis risus nisl dictum diam. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Vivamus vel euismod nibh, et lobortis dolor. Maecenas dui odio, gravida nec molestie ut, feugiat ut arcu. Pellentesque risus sapien, gravida a convallis quis, ullamcorper porttitor sapien. - -Donec ipsum eros, vestibulum sit amet cursus eget, iaculis quis dolor. Pellentesque magna augue, tristique dapibus mi vitae, molestie venenatis enim. Nam malesuada, turpis volutpat rhoncus ullamcorper, justo est eleifend orci, ut luctus risus ex rutrum arcu. Sed mi elit, feugiat rhoncus ornare sed, porta id leo. Pellentesque feugiat nulla tincidunt erat suscipit, eu congue lacus hendrerit. Morbi pulvinar enim sed consequat auctor. Ut eleifend enim sem, vitae euismod ex ultricies sit amet. Curabitur eu efficitur nisi, suscipit finibus sapien. In sodales blandit erat, vestibulum pulvinar ante volutpat nec. Vivamus dictum non libero at molestie. Donec sit amet neque in ante convallis pretium. Nunc vel iaculis dui. - -Phasellus eu nunc ut nunc faucibus laoreet. Aliquam at magna risus. Praesent lobortis, risus finibus semper varius, magna purus vestibulum eros, at pulvinar sapien enim a ex. In scelerisque malesuada ex, sit amet egestas neque condimentum sed. Praesent vulputate efficitur massa. Cras at accumsan ligula. In elementum lectus eget blandit dictum. Nam vitae libero ut justo eleifend rutrum ac nec arcu. Aliquam sodales in quam congue vestibulum. Aliquam in accumsan sapien. Quisque lobortis nisl nisi, vitae bibendum turpis efficitur sed. Vestibulum tempor nulla eget nisi convallis, blandit sagittis ipsum convallis. Donec odio nibh, ultrices quis odio in, mollis euismod libero. - -### Scene Five - -@pov: Bod -@plot: Main -@location: Europe - -% Synopsis: Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. - -Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. In sed felis auctor, rhoncus dui ac, consequat dolor. Integer volutpat libero sed nisl aliquet varius. Suspendisse et lorem sapien. Proin id ultrices nibh, ac suscipit diam. Suspendisse placerat varius porttitor. Curabitur elementum sed enim ultrices imperdiet. - -In ut lobortis lacus, nec luctus arcu. Vivamus condimentum sapien a ipsum malesuada sodales. Donec et vestibulum risus. Integer dictum euismod eros id tincidunt. Aliquam sagittis leo vitae consequat fermentum. Donec maximus ex eu ex iaculis porta. Praesent pharetra lacinia risus, et eleifend diam commodo non. Sed feugiat ipsum ut orci sagittis, quis faucibus lectus blandit. Sed tellus quam, gravida vitae laoreet quis, tempus lobortis dui. Vivamus semper accumsan ullamcorper. Praesent tempus pretium eros, non elementum risus. Pellentesque odio quam, auctor quis ex non, vulputate egestas dolor. Nunc luctus enim ut justo sodales consectetur. Sed aliquet a mauris vel posuere. - -Donec luctus lectus efficitur, blandit nisi vitae, dignissim tellus. Pellentesque euismod pharetra augue gravida hendrerit. Quisque nisi mi, mattis ac nisi non, maximus malesuada ante. Nulla lobortis, diam eu ornare ornare, tellus enim feugiat arcu, non vestibulum tortor nunc eu justo. Integer blandit felis justo, eu semper est scelerisque vel. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nam ultricies, nisi vel elementum commodo, nisl dolor tincidunt magna, sed varius est nunc at lectus. Aliquam dolor tortor, sodales placerat ultricies quis, sodales quis sapien. Duis ullamcorper sollicitudin risus at mattis. Integer consequat et nunc at condimentum. Pellentesque cursus congue augue, non suscipit lectus sodales ut. Nam a mi bibendum, blandit nisl eu, accumsan nunc. Aliquam a ex mauris. Sed nec sem quis arcu dignissim tempus eget et turpis. Ut sed ex nec ipsum ultrices lobortis. - -Pellentesque rhoncus pharetra eros, non mollis nisi pretium non. Mauris accumsan quis odio quis euismod. Maecenas ultrices, augue et aliquam tincidunt, erat tellus ornare ligula, quis ultrices turpis nibh vel justo. Fusce gravida odio tellus. In a congue diam. Mauris consequat ex id leo lacinia dictum. Fusce id sem sodales, ultrices sapien ac, convallis orci. Donec gravida nunc sit amet nisi hendrerit, sed porta enim aliquam. In hac habitasse platea dictumst. Cras a orci felis. Curabitur non felis nec urna maximus auctor ut ut nisi. Curabitur at turpis eleifend, blandit eros at, molestie odio. Phasellus euismod neque augue. - -Integer egestas maximus leo eu facilisis. Nunc rhoncus dignissim lectus eu lacinia. Praesent lacinia urna porttitor aliquam condimentum. Nulla eu eros dictum, dictum nunc vitae, sagittis nibh. Integer ante neque, consequat nec sollicitudin id, consectetur vitae dolor. Nullam volutpat sem orci, quis viverra magna auctor a. Suspendisse potenti. Maecenas commodo sed neque pellentesque vehicula. Sed luctus nisl risus, elementum semper purus interdum vel. Ut pulvinar, massa sit amet venenatis placerat, nunc lacus hendrerit odio, non aliquet nunc risus eu lectus. Maecenas feugiat semper ligula, id lobortis sem porta eu. Integer posuere elit magna, at mollis eros bibendum et. Ut imperdiet purus vel nulla aliquam maximus. Morbi sodales purus tellus, a rhoncus sem rutrum sit amet. Quisque risus sem, laoreet nec convallis nec, rutrum vitae justo. - -# Notes: Characters - -# Nobody Owens - -@tag: Bod -@plot: Main - -Pellentesque nec erat ut nulla posuere commodo. Curabitur nisi augue, imperdiet et porta imperdiet, efficitur id leo. Cras finibus arcu at nibh commodo congue. Proin suscipit placerat condimentum. Aenean ante enim, cursus id lorem a, blandit venenatis nibh. Maecenas suscipit porta elit, sit amet porta felis porttitor eu. Sed a dui nibh. Phasellus sed faucibus dui. Pellentesque felis nulla, ultrices non efficitur quis, rutrum id mi. Mauris tempus auctor nisl, in bibendum enim pellentesque sit amet. Proin nunc lacus, imperdiet nec posuere ac, interdum non lectus. - -Suspendisse faucibus est auctor orci mollis luctus. Praesent quis sodales neque. Interdum et malesuada fames ac ante ipsum primis in faucibus. Donec sodales rutrum mattis. In in sem ornare, consequat nulla ac, convallis arcu. Duis ac metus id felis commodo commodo sit amet eget diam. Curabitur rhoncus lacinia leo at sodales. Etiam finibus porta diam a viverra. Praesent nisi urna, volutpat sit amet odio at, vehicula vehicula leo. In non enim eget nisl luctus commodo. Pellentesque pellentesque at lectus at luctus. Quisque nec felis bibendum, lacinia libero ut, lacinia eros. Integer finibus ultricies nibh sit amet placerat. - -Nullam scelerisque velit et tortor congue vestibulum a at nisi. Vivamus sodales ut turpis a convallis. In dignissim nibh at luctus sodales. Etiam sit amet rhoncus massa. Phasellus ligula magna, sollicitudin non imperdiet sit amet, volutpat vel magna. Nunc vestibulum tempor lectus, sit amet porta nunc hendrerit in. Curabitur non odio sit amet massa tincidunt facilisis. Integer et luctus nunc, eget euismod leo. Praesent faucibus metus sed purus convallis scelerisque. Fusce viverra lorem et placerat malesuada. In at elit malesuada, ullamcorper risus vitae, sodales dolor. Donec quis elementum lectus. Quisque eu eros at dui imperdiet euismod ut id neque. - -# Notes: Plot - -# Main Plot - -@tag: Main - -Suspendisse vulputate malesuada pellentesque. Aenean sollicitudin cursus mi, vitae ultricies felis ullamcorper eu. Duis luctus risus mi, in accumsan velit cursus ut. Vestibulum eleifend leo in magna eleifend fermentum. Proin nec ornare elit. Phasellus nec interdum risus. In a volutpat augue, quis egestas justo. Morbi porta mauris mattis bibendum imperdiet. - -Mauris ut erat eu lorem malesuada egestas vel vel urna. Maecenas ac semper quam. Maecenas aliquet metus non interdum mattis. Proin consectetur molestie ligula. Aliquam sollicitudin pulvinar urna a pellentesque. Suspendisse ultrices, est mattis scelerisque porta, nisi nisi laoreet nisl, non condimentum quam ante a velit. Proin scelerisque justo augue, nec laoreet ligula egestas at. Etiam enim quam, ultrices non accumsan hendrerit, elementum vel ligula. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Nam efficitur odio libero, in vestibulum arcu aliquam at. Cras non vehicula augue. Integer lobortis, est vitae aliquam facilisis, metus ligula aliquet eros, at porttitor sem tortor eget massa. Aliquam varius scelerisque neque sed gravida. Aenean eleifend lorem id ante elementum sollicitudin. Proin commodo massa a quam volutpat, mollis fermentum turpis efficitur. - -# Notes: World - -# Ancient Europe - -@tag: Europe - -Vivamus sodales risus ac accumsan posuere. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nunc vel enim felis. Vestibulum dignissim massa nunc, a auctor magna eleifend et. Proin dignissim sodales erat vitae convallis. Aliquam id tellus dui. Curabitur sollicitudin scelerisque ex sit amet posuere. Nam rutrum felis id rhoncus feugiat. Duis sagittis quam quis purus efficitur, quis rutrum odio iaculis. Maecenas semper ante turpis, at vulputate mi consectetur non. Sed rutrum nibh turpis, quis rhoncus purus ornare quis. Vestibulum at rutrum mauris. Integer dolor nisi, tincidunt eget vehicula ac, ultricies at ligula. - -Aenean semper turpis quis varius rhoncus. Vivamus ac mi eget felis euismod vulputate. Nam eu tempus velit. Etiam ut est porta, finibus erat sit amet, consectetur felis. Nullam consequat felis ut lacus pharetra, in lobortis urna mollis. Nulla varius eros nec lorem rhoncus, sed venenatis risus ultrices. Phasellus pellentesque laoreet neque, ut ultricies lacus vulputate quis. In malesuada dui sit amet est interdum, eget consectetur mi gravida. Cras vel bibendum purus. Quisque commodo tempor arcu, non lacinia sem blandit eleifend. Quisque at neque gravida, porttitor metus a, suscipit diam. Quisque convallis sodales lacus et condimentum. Donec a suscipit diam. Pellentesque eget cursus neque. - -Nunc ullamcorper magna quis elit condimentum rhoncus. Aenean dictum pulvinar dolor suscipit interdum. Aliquam elit massa, elementum nec cursus eu, maximus nec ipsum. Donec ullamcorper iaculis dolor eu commodo. Nunc eget tortor quis turpis consectetur varius. Vestibulum nec justo vel tellus venenatis condimentum. Duis auctor iaculis massa. Nunc risus magna, rutrum vitae eros non, tristique mollis enim. - diff --git a/tests/reference/guiBuild_Tool_Step4H_Lorem_Ipsum.json b/tests/reference/guiBuild_Tool_Step4H_Lorem_Ipsum.json deleted file mode 100644 index 87915317..00000000 --- a/tests/reference/guiBuild_Tool_Step4H_Lorem_Ipsum.json +++ /dev/null @@ -1,91 +0,0 @@ -{ - "meta": { - "projectName": "Lorem Ipsum", - "novelTitle": "Lorem Ipsum", - "novelAuthor": "lipsum.com", - "buildTime": 1669563813 - }, - "text": { - "css": [ - "body {font-family: 'DejaVu Sans'; font-size: 11pt;}", - "p {text-align: justify; line-height: 115%; margin-top: 0.00em; margin-bottom: 0.58em;}", - "h1 {color: rgb(66, 113, 174); page-break-after: avoid; margin-top: 1.00em; margin-bottom: 0.50em;}", - "h2 {color: rgb(66, 113, 174); page-break-after: avoid; margin-top: 0.83em; margin-bottom: 0.50em;}", - "h3 {color: rgb(50, 50, 50); page-break-after: avoid; margin-top: 0.58em; margin-bottom: 0.50em;}", - "h4 {color: rgb(50, 50, 50); page-break-after: avoid; margin-top: 0.58em; margin-bottom: 0.50em;}", - ".title {font-size: 2.5em; margin-top: 1.00em; margin-bottom: 0.50em;}", - ".sep, .skip {text-align: center; margin-top: 1.00em; margin-bottom: 1.00em;}", - "a {color: rgb(66, 113, 174);}", - ".tags {color: rgb(245, 135, 31); font-weight: bold;}", - ".break {text-align: left;}", - ".synopsis {font-style: italic;}", - ".comment {font-style: italic; color: rgb(100, 100, 100);}" - ], - "html": [ - [ - "

Lorem Ipsum

" - ], - [ - "" - ], - [ - "

Prologue

", - "

Synopsis: Explanation from the lipsum.com website.

" - ], - [ - "

Act One

" - ], - [ - "

Chapter One: Chapter One

", - "

Point of View: Bod

", - "

Plot: Main

", - "

Locations: Europe

", - "

Synopsis: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam.

" - ], - [ - "

Scene 1: Scene One

", - "

Point of View: Bod

", - "

Plot: Main

", - "

Locations: Europe

", - "

Synopsis: Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur.

", - "

Section: Scene One, Section Two

" - ], - [ - "

Scene 2: Scene Two

", - "

Point of View: Bod

", - "

Plot: Main

", - "

Locations: Europe

", - "

Synopsis: Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci.

", - "

Section: Scene Two, Section Two

" - ], - [ - "

Chapter Two: Chapter Two

", - "

Point of View: Bod

", - "

Plot: Main

", - "

Locations: Europe

", - "

Synopsis: Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue.

" - ], - [ - "

Scene 3: Scene Three

", - "

Point of View: Bod

", - "

Plot: Main

", - "

Locations: Europe

", - "

Synopsis: Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.

" - ], - [ - "

Scene 4: Scene Four

", - "

Point of View: Bod

", - "

Plot: Main

", - "

Locations: Europe

", - "

Synopsis: Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo.

" - ], - [ - "

Scene 5: Scene Five

", - "

Point of View: Bod

", - "

Plot: Main

", - "

Locations: Europe

", - "

Synopsis: Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.

" - ] - ] - } -} diff --git a/tests/reference/guiBuild_Tool_Step4M_Lorem_Ipsum.json b/tests/reference/guiBuild_Tool_Step4M_Lorem_Ipsum.json deleted file mode 100644 index 75a54741..00000000 --- a/tests/reference/guiBuild_Tool_Step4M_Lorem_Ipsum.json +++ /dev/null @@ -1,149 +0,0 @@ -{ - "meta": { - "projectName": "Lorem Ipsum", - "novelTitle": "Lorem Ipsum", - "novelAuthor": "lipsum.com", - "buildTime": 1669563891 - }, - "text": { - "nwd": [ - [ - "#! Lorem Ipsum", - "", - "", - "", - "", - "" - ], - [ - "", - "", - "", - "", - "" - ], - [ - "##! Prologue", - "", - "% Synopsis:Explanation from the lipsum.com website.", - "", - "", - "" - ], - [ - "# Act One", - "", - "", - "" - ], - [ - "## Chapter One", - "", - "@pov: Bod", - "@plot: Main", - "@location: Europe", - "", - "% Synopsis: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam.", - "", - "", - "" - ], - [ - "### Scene One", - "", - "@pov: Bod", - "@plot: Main", - "@location: Europe", - "", - "% Synopsis: Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur.", - "", - "", - "", - "#### Scene One, Section Two", - "", - "", - "", - "" - ], - [ - "### Scene Two", - "", - "@pov: Bod", - "@plot: Main", - "@location: Europe", - "", - "% Synopsis: Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci. ", - "", - "", - "", - "", - "#### Scene Two, Section Two", - "", - "", - "", - "", - "" - ], - [ - "## Chapter Two", - "", - "@pov: Bod", - "@plot: Main", - "@location: Europe", - "", - "% Synopsis: Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue.", - "", - "", - "" - ], - [ - "### Scene Three", - "", - "@pov: Bod", - "@plot: Main", - "@location: Europe", - "", - "% Synopsis: Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.", - "", - "", - "", - "", - "", - "" - ], - [ - "### Scene Four", - "", - "@pov: Bod", - "@plot: Main", - "@location: Europe", - "", - "% Synopsis: Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo.", - "", - "", - "", - "", - "", - "", - "", - "" - ], - [ - "### Scene Five", - "", - "@pov: Bod", - "@plot: Main", - "@location: Europe", - "", - "% Synopsis: Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.", - "", - "", - "", - "", - "", - "", - "" - ] - ] - } -} diff --git a/tests/reference/guiBuild_Tool_Step4_Lorem_Ipsum.htm b/tests/reference/guiBuild_Tool_Step4_Lorem_Ipsum.htm deleted file mode 100644 index 20f6e6b6..00000000 --- a/tests/reference/guiBuild_Tool_Step4_Lorem_Ipsum.htm +++ /dev/null @@ -1,68 +0,0 @@ - - - - -Lorem Ipsum - - - -
-

Lorem Ipsum

-

Prologue

-

Synopsis: Explanation from the lipsum.com website.

-

Act One

-

Chapter One: Chapter One

-

Point of View: Bod

-

Plot: Main

-

Locations: Europe

-

Synopsis: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam.

-

Scene 1: Scene One

-

Point of View: Bod

-

Plot: Main

-

Locations: Europe

-

Synopsis: Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur.

-

Section: Scene One, Section Two

-

Scene 2: Scene Two

-

Point of View: Bod

-

Plot: Main

-

Locations: Europe

-

Synopsis: Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci.

-

Section: Scene Two, Section Two

-

Chapter Two: Chapter Two

-

Point of View: Bod

-

Plot: Main

-

Locations: Europe

-

Synopsis: Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue.

-

Scene 3: Scene Three

-

Point of View: Bod

-

Plot: Main

-

Locations: Europe

-

Synopsis: Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.

-

Scene 4: Scene Four

-

Point of View: Bod

-

Plot: Main

-

Locations: Europe

-

Synopsis: Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo.

-

Scene 5: Scene Five

-

Point of View: Bod

-

Plot: Main

-

Locations: Europe

-

Synopsis: Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.

-
- - diff --git a/tests/reference/guiBuild_Tool_Step4_Lorem_Ipsum.nwd b/tests/reference/guiBuild_Tool_Step4_Lorem_Ipsum.nwd deleted file mode 100644 index fa1b8656..00000000 --- a/tests/reference/guiBuild_Tool_Step4_Lorem_Ipsum.nwd +++ /dev/null @@ -1,104 +0,0 @@ -#! Lorem Ipsum - - - - - - - - -##! Prologue - -% Synopsis:Explanation from the lipsum.com website. - - -# Act One - - -## Chapter One - -@pov: Bod -@plot: Main -@location: Europe - -% Synopsis: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam. - - -### Scene One - -@pov: Bod -@plot: Main -@location: Europe - -% Synopsis: Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur. - - - -#### Scene One, Section Two - - - -### Scene Two - -@pov: Bod -@plot: Main -@location: Europe - -% Synopsis: Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci. - - - - -#### Scene Two, Section Two - - - - -## Chapter Two - -@pov: Bod -@plot: Main -@location: Europe - -% Synopsis: Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue. - - -### Scene Three - -@pov: Bod -@plot: Main -@location: Europe - -% Synopsis: Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. - - - - - -### Scene Four - -@pov: Bod -@plot: Main -@location: Europe - -% Synopsis: Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo. - - - - - - - -### Scene Five - -@pov: Bod -@plot: Main -@location: Europe - -% Synopsis: Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. - - - - - - From db8140418e06f1a3908eb44ba82afa3beadc900b Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 3 Jun 2023 18:05:04 +0200 Subject: [PATCH 51/74] Fix typing and docstrings in Project data and XML classes --- novelwriter/common.py | 10 +- novelwriter/core/projectdata.py | 155 ++++++++++++----------- novelwriter/core/projectxml.py | 216 ++++++++++++++++---------------- 3 files changed, 192 insertions(+), 189 deletions(-) diff --git a/novelwriter/common.py b/novelwriter/common.py index 3ccdd751..386681e4 100644 --- a/novelwriter/common.py +++ b/novelwriter/common.py @@ -1,7 +1,6 @@ """ novelWriter – Common Functions ============================== -Various common functions File History: Created: 2019-05-12 [0.1] @@ -113,9 +112,8 @@ def checkHandle(value, default, allowNone=False): return default -def checkUuid(value, default): - """Try to process a value as an uuid, or return a default. - """ +def checkUuid(value: Any, default: str) -> str: + """Try to process a value as an uuid, or return a default.""" try: return str(uuid.UUID(value)) except Exception: @@ -236,7 +234,7 @@ def formatInt(value): return str(value) -def formatTimeStamp(value, fileSafe=False): +def formatTimeStamp(value: float, fileSafe: bool = False) -> str: """Take a number (on the format returned by time.time()) and convert it to a timestamp string. """ @@ -246,7 +244,7 @@ def formatTimeStamp(value, fileSafe=False): return datetime.fromtimestamp(value).strftime(nwConst.FMT_TSTAMP) -def formatTime(t): +def formatTime(t: int) -> str: """Format a time in seconds in HH:MM:SS format or d-HH:MM:SS format if a full day or longer. """ diff --git a/novelwriter/core/projectdata.py b/novelwriter/core/projectdata.py index ad376cd1..50c48086 100644 --- a/novelwriter/core/projectdata.py +++ b/novelwriter/core/projectdata.py @@ -1,7 +1,6 @@ """ novelWriter – Project Data Class ================================ -Data class for novelWriter projects File History: Created: 2022-10-30 [2.0rc2] @@ -27,6 +26,8 @@ from __future__ import annotations import uuid import logging +from typing import Any + from novelwriter.common import ( checkBool, checkInt, checkStringNone, checkUuid, isHandle, simplified ) @@ -36,6 +37,11 @@ logger = logging.getLogger(__name__) class NWProjectData: + """Core: Project Data Class + + The class holds all project data from the main XML file, aside from + the list of project items. + """ def __init__(self, theProject): @@ -84,75 +90,99 @@ class NWProjectData: ## @property - def uuid(self): + def uuid(self) -> str: + """Return the project ID.""" return self._uuid @property - def name(self): + def name(self) -> str: + """Return the project name.""" return self._name @property - def title(self): + def title(self) -> str: + """Return the project title.""" return self._title @property - def author(self): + def author(self) -> str: + """Return the project author.""" return self._author @property - def saveCount(self): + def saveCount(self) -> int: + """Return the count of project saves.""" return self._saveCount @property - def autoCount(self): + def autoCount(self) -> int: + """Return the count of project auto-saves.""" return self._autoCount @property - def editTime(self): + def editTime(self) -> int: + """Return the number of seconds the project has been edited.""" return self._editTime @property - def doBackup(self): + def doBackup(self) -> bool: + """Return the backup setting.""" return self._doBackup @property - def language(self): + def language(self) -> str | None: + """Return the project language setting.""" return self._language @property - def spellCheck(self): + def spellCheck(self) -> bool: + """Return the spell check enabled setting.""" return self._spellCheck @property - def spellLang(self): + def spellLang(self) -> str | None: + """Return the spell check language.""" return self._spellLang @property - def initCounts(self): + def initCounts(self) -> tuple[int, int]: + """Return the initial count of words for novel and note + documents. + """ return tuple(self._initCounts) @property - def currCounts(self): + def currCounts(self) -> tuple[int, int]: + """Return the current count of words for novel and note + documents. + """ return tuple(self._currCounts) @property - def lastHandle(self): + def lastHandle(self) -> dict[str, str | None]: + """Return the dictionary of last used handles for various + components of the GUI. + """ return self._lastHandle @property - def autoReplace(self): + def autoReplace(self) -> dict[str, str]: + """Return the autoreplace dictionary.""" return self._autoReplace @property def titleFormat(self): + """Delete""" return self._titleFormat @property - def itemStatus(self): + def itemStatus(self) -> NWStatus: + """Return the status settings object.""" return self._status @property - def itemImport(self): + def itemImport(self) -> NWStatus: + """Return the importance settings object.""" return self._import ## @@ -160,15 +190,13 @@ class NWProjectData: ## def incSaveCount(self): - """Increment the save count by one. - """ + """Increment the save count by one.""" self._saveCount += 1 self.theProject.setProjectChanged(True) return def incAutoCount(self): - """Increment the auto save count by one. - """ + """Increment the auto save count by one.""" self._autoCount += 1 self.theProject.setProjectChanged(True) return @@ -177,23 +205,20 @@ class NWProjectData: # Getters ## - def getLastHandle(self, component): - """Retrieve the last used handle for a given component. - """ + def getLastHandle(self, component: str) -> str | None: + """Retrieve the last used handle for a given component.""" return self._lastHandle.get(component, None) def getTitleFormat(self, kind): - """Retrieve the title format string for a given kind of header. - """ + """Retrieve the title format string for a given kind of header.""" return self._titleFormat.get(kind, "%title%") ## # Setters ## - def setUuid(self, value): - """Set the project id. - """ + def setUuid(self, value: Any): + """Set the project id.""" value = checkUuid(value, "") if not value: self._uuid = str(uuid.uuid4()) @@ -202,84 +227,74 @@ class NWProjectData: self.theProject.setProjectChanged(True) return - def setName(self, value): - """Set a new project name. - """ + def setName(self, value: str | None): + """Set a new project name.""" if value != self._name: self._name = simplified(str(value or "")) self.theProject.setProjectChanged(True) return - def setTitle(self, value): - """Set a new novel title. - """ + def setTitle(self, value: str | None): + """Set a new novel title.""" if value != self._title: self._title = simplified(str(value or "")) self.theProject.setProjectChanged(True) return - def setAuthor(self, value): - """Set the author value. - """ + def setAuthor(self, value: str | None): + """Set the author value.""" if value != self._title: self._author = simplified(str(value or "")) self.theProject.setProjectChanged(True) return - def setSaveCount(self, value): - """Set the save count from last session. - """ + def setSaveCount(self, value: Any): + """Set the save count from last session.""" self._saveCount = checkInt(value, 0) self.theProject.setProjectChanged(True) return - def setAutoCount(self, value): - """Set the auto save count from last session. - """ + def setAutoCount(self, value: Any): + """Set the auto save count from last session.""" self._autoCount = checkInt(value, 0) self.theProject.setProjectChanged(True) return - def setEditTime(self, value): - """Set tyje edit time from last session. - """ + def setEditTime(self, value: Any): + """Set tyje edit time from last session.""" self._editTime = checkInt(value, 0) self.theProject.setProjectChanged(True) return - def setDoBackup(self, value): - """Set the do write backup flag. - """ + def setDoBackup(self, value: Any): + """Set the do write backup flag.""" if value != self._doBackup: self._doBackup = checkBool(value, False) self.theProject.setProjectChanged(True) return - def setLanguage(self, value): - """Set the project language. - """ + def setLanguage(self, value: str | None): + """Set the project language.""" if value != self._language: self._language = checkStringNone(value, None) self.theProject.setProjectChanged(True) return - def setSpellCheck(self, value): - """Set the spell check flag. - """ + def setSpellCheck(self, value: Any): + """Set the spell check flag.""" if value != self._spellCheck: self._spellCheck = checkBool(value, False) self.theProject.setProjectChanged(True) return - def setSpellLang(self, value): - """Set the spell check language. - """ + def setSpellLang(self, value: str | None): + """Set the spell check language.""" if value != self._spellLang: self._spellLang = checkStringNone(value, None) self.theProject.setProjectChanged(True) return - def setLastHandle(self, value, component=None): + def setLastHandle(self, value: dict, component: str | None = None): """Set a last used handle into the handle registry. If component is None, the value is assumed to be the whole dictionary of values. @@ -294,9 +309,8 @@ class NWProjectData: self.theProject.setProjectChanged(True) return - def setInitCounts(self, novel=None, notes=None): - """Set the worc count totals for novel and note files. - """ + def setInitCounts(self, novel: Any = None, notes: Any = None): + """Set the word count totals for novel and note files.""" if novel is not None: self._initCounts[0] = checkInt(novel, 0) self._currCounts[0] = checkInt(novel, 0) @@ -305,18 +319,16 @@ class NWProjectData: self._currCounts[1] = checkInt(notes, 0) return - def setCurrCounts(self, novel=None, notes=None): - """Set the worc count totals for novel and note files. - """ + def setCurrCounts(self, novel: Any = None, notes: Any = None): + """Set the word count totals for novel and note files.""" if novel is not None: self._currCounts[0] = checkInt(novel, 0) if notes is not None: self._currCounts[1] = checkInt(notes, 0) return - def setAutoReplace(self, value): - """Set the auto-replace dictionary. - """ + def setAutoReplace(self, value: dict): + """Set the auto-replace dictionary.""" if isinstance(value, dict): self._autoReplace = {} for key, entry in value.items(): @@ -326,8 +338,7 @@ class NWProjectData: return def setTitleFormat(self, value): - """Set the title formats. - """ + """Set the title formats.""" if isinstance(value, dict): for key, entry in value.items(): if key in self._titleFormat and isinstance(entry, str): diff --git a/novelwriter/core/projectxml.py b/novelwriter/core/projectxml.py index b39356a2..0726d7da 100644 --- a/novelwriter/core/projectxml.py +++ b/novelwriter/core/projectxml.py @@ -1,7 +1,6 @@ """ novelWriter – Project XML Read/Write ==================================== -Classes for reading and writing the project XML file File History: Created: 2022-09-28 [2.0rc2] XMLReadState @@ -24,6 +23,7 @@ General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . """ +from __future__ import annotations import logging import xml.etree.ElementTree as ET @@ -31,6 +31,7 @@ import xml.etree.ElementTree as ET from enum import Enum from time import time from pathlib import Path +from typing import TYPE_CHECKING from novelwriter import __version__, __hexversion__ from novelwriter.common import ( @@ -39,6 +40,10 @@ from novelwriter.common import ( ) from novelwriter.constants import nwFiles +if TYPE_CHECKING: + from novelwriter.core.status import NWStatus + from novelwriter.core.projectdata import NWProjectData + logger = logging.getLogger(__name__) FILE_VERSION = "1.5" # The current project file format version @@ -55,6 +60,7 @@ NUM_VERSION = { class XMLReadState(Enum): + """The state of an XML read process.""" NO_ACTION = 0 NO_ERROR = 1 @@ -69,8 +75,10 @@ class XMLReadState(Enum): class ProjectXMLReader: - """The main project XML file reader class. All data is read into a - NWProjectData instance, which must be provided. + """Core: Project XML Reader + + All data is read into a NWProjectData instance, which must be + provided. File Format Version Change History ================================== @@ -100,16 +108,13 @@ class ProjectXMLReader: """ def __init__(self, path): - self._path = Path(path) self._state = XMLReadState.NO_ACTION - self._root = "" self._version = 0x0 self._appVersion = "" self._hexVersion = 0x0 self._timeStamp = "" - return ## @@ -117,55 +122,46 @@ class ProjectXMLReader: ## @property - def state(self): - """The state of the parsing as an XMLReadState enum value. - """ + def state(self) -> XMLReadState: + """Return the parsing state.""" return self._state @property - def xmlRoot(self): - """The root tag name of the XNL file, - """ + def xmlRoot(self) -> str: + """Return the root tag name of the XNL file.""" return self._root @property - def xmlVersion(self): - """The project XML version number. - """ + def xmlVersion(self) -> int: + """Return the project XML version number.""" return self._version @property - def appVersion(self): - """The novelWriter version number who wrote the file. - """ + def appVersion(self) -> str: + """Return the version number who wrote the file.""" return self._appVersion @property - def hexVersion(self): - """The novelWriter version number who wrote the file as hex. - """ + def hexVersion(self) -> int: + """Return the version number who wrote the file as hex.""" return self._hexVersion @property - def timeStamp(self): - """The date and time when the file was written. - """ + def timeStamp(self) -> str: + """Return the date and time when the file was written.""" return self._timeStamp ## # Methods ## - def read(self, projData, projContent): - """Read and parse the project XML file. - """ + def read(self, data: NWProjectData, content: list) -> bool: + """Read and parse the project XML file.""" tStart = time() logger.debug("Reading project XML") - try: xml = ET.parse(str(self._path)) self._state = XMLReadState.NO_ERROR - except Exception as exc: # Trying to open backup file instead logger.error("Failed to parse project XML", exc_info=exc) @@ -206,14 +202,14 @@ class ProjectXMLReader: for xSection in xRoot: if xSection.tag == "project": - self._parseProjectMeta(xSection, projData) + self._parseProjectMeta(xSection, data) elif xSection.tag == "settings": - self._parseProjectSettings(xSection, projData) + self._parseProjectSettings(xSection, data) elif xSection.tag == "content": if self._version >= 0x0104: - self._parseProjectContent(xSection, projData, projContent) + self._parseProjectContent(xSection, data, content) else: - self._parseProjectContentLegacy(xSection, projData, projContent) + self._parseProjectContentLegacy(xSection, data, content) else: logger.warning("Ignored in XML", xSection.tag) @@ -230,23 +226,22 @@ class ProjectXMLReader: # Internal Functions ## - def _parseProjectMeta(self, xSection, projData): - """Parse the project section of the XML file. - """ + def _parseProjectMeta(self, xSection: ET.Element, data: NWProjectData): + """Parse the project section of the XML file.""" logger.debug("Parsing section") - projData.setUuid(xSection.attrib.get("id", None)) # Added in 1.5 - projData.setSaveCount(xSection.attrib.get("saveCount", 0)) # Moved in 1.5 - projData.setAutoCount(xSection.attrib.get("autoCount", 0)) # Moved in 1.5 - projData.setEditTime(xSection.attrib.get("editTime", 0)) # Moved in 1.5 + data.setUuid(xSection.attrib.get("id", None)) # Added in 1.5 + data.setSaveCount(xSection.attrib.get("saveCount", 0)) # Moved in 1.5 + data.setAutoCount(xSection.attrib.get("autoCount", 0)) # Moved in 1.5 + data.setEditTime(xSection.attrib.get("editTime", 0)) # Moved in 1.5 for xItem in xSection: if xItem.tag == "name": - projData.setName(xItem.text) + data.setName(xItem.text) elif xItem.tag == "title": - projData.setTitle(xItem.text) + data.setTitle(xItem.text) elif xItem.tag == "author": - projData.setAuthor(xItem.text) + data.setAuthor(xItem.text) else: logger.warning("Ignored in XML", xItem.tag) @@ -254,43 +249,42 @@ class ProjectXMLReader: if self._version < HEX_VERSION: for xItem in xSection: if xItem.tag == "saveCount": # Moved to attribute in 1.5 - projData.setSaveCount(xItem.text) + data.setSaveCount(xItem.text) elif xItem.tag == "autoCount": # Moved to attribute in 1.5 - projData.setAutoCount(xItem.text) + data.setAutoCount(xItem.text) elif xItem.tag == "editTime": # Moved to attribute in 1.5 - projData.setEditTime(xItem.text) + data.setEditTime(xItem.text) return - def _parseProjectSettings(self, xSection, projData): - """Parse the settings section of the XML file. - """ + def _parseProjectSettings(self, xSection: ET.Element, data: NWProjectData): + """Parse the settings section of the XML file.""" logger.debug("Parsing section") for xItem in xSection: if xItem.tag == "doBackup": - projData.setDoBackup(xItem.text) + data.setDoBackup(xItem.text) elif xItem.tag == "language": - projData.setLanguage(xItem.text) + data.setLanguage(xItem.text) elif xItem.tag == "spellChecking": - projData.setSpellLang(xItem.text) - projData.setSpellCheck(xItem.attrib.get("auto", False)) + data.setSpellLang(xItem.text) + data.setSpellCheck(xItem.attrib.get("auto", False)) elif xItem.tag == "status": - self._parseStatusImport(xItem, projData.itemStatus) + self._parseStatusImport(xItem, data.itemStatus) elif xItem.tag == "importance": - self._parseStatusImport(xItem, projData.itemImport) + self._parseStatusImport(xItem, data.itemImport) elif xItem.tag == "lastHandle": - projData.setLastHandle(self._parseDictKeyText(xItem)) + data.setLastHandle(self._parseDictKeyText(xItem)) elif xItem.tag == "autoReplace": if self._version >= 0x0102: - projData.setAutoReplace(self._parseDictKeyText(xItem)) + data.setAutoReplace(self._parseDictKeyText(xItem)) else: # Pre 1.2 format - projData.setAutoReplace(self._parseDictTagText(xItem)) + data.setAutoReplace(self._parseDictTagText(xItem)) elif xItem.tag == "titleFormat": if self._version >= 0x0105: - projData.setTitleFormat(self._parseDictKeyText(xItem)) + data.setTitleFormat(self._parseDictKeyText(xItem)) else: # Pre 1.4 format - projData.setTitleFormat(self._parseDictTagText(xItem)) + data.setTitleFormat(self._parseDictTagText(xItem)) else: logger.warning("Ignored in XML", xItem.tag) @@ -298,23 +292,22 @@ class ProjectXMLReader: if self._version < HEX_VERSION: for xItem in xSection: if xItem.tag == "spellCheck": # Changed to spellChecking in 1.5 - projData.setSpellCheck(xItem.text) + data.setSpellCheck(xItem.text) elif xItem.tag == "spellLang": # Changed to spellChecking in 1.5 - projData.setSpellLang(xItem.text) + data.setSpellLang(xItem.text) elif xItem.tag == "novelWordCount": # Moved to content attribute in 1.5 - projData.setInitCounts(novel=xItem.text) + data.setInitCounts(novel=xItem.text) elif xItem.tag == "notesWordCount": # Moved to content attribute in 1.5 - projData.setInitCounts(notes=xItem.text) + data.setInitCounts(notes=xItem.text) return - def _parseProjectContent(self, xSection, projData, projContent): - """Parse the content section of the XML file. - """ + def _parseProjectContent(self, xSection: ET.Element, data: NWProjectData, content: list): + """Parse the content section of the XML file.""" logger.debug("Parsing section") - projData.setInitCounts(novel=xSection.attrib.get("novelWords", None)) # Moved in 1.5 - projData.setInitCounts(notes=xSection.attrib.get("notesWords", None)) # Moved in 1.5 + data.setInitCounts(novel=xSection.attrib.get("novelWords", None)) # Moved in 1.5 + data.setInitCounts(notes=xSection.attrib.get("notesWords", None)) # Moved in 1.5 for xItem in xSection: if xItem.tag != "item": @@ -355,7 +348,7 @@ class ProjectXMLReader: if xVal.tag == "name" and "exported" in xVal.attrib: name["active"] = checkBool(xVal.attrib.get("exported"), False) - projContent.append({ + content.append({ "name": itemName, "itemAttr": item, "metaAttr": meta, @@ -364,14 +357,13 @@ class ProjectXMLReader: return - def _parseProjectContentLegacy(self, xSection, projData, projContent): - """Parse the content section of the XML file for older versions. - """ + def _parseProjectContentLegacy(self, xSection: ET.Element, data: NWProjectData, content: list): + """Parse the content section of the XML file for older versions.""" logger.debug("Parsing section (legacy format)") # Create maps to look up name -> key for status and importance - statusMap = {entry.get("name"): key for key, entry in projData.itemStatus.items()} - importMap = {entry.get("name"): key for key, entry in projData.itemImport.items()} + statusMap = {entry.get("name"): key for key, entry in data.itemStatus.items()} + importMap = {entry.get("name"): key for key, entry in data.itemImport.items()} for xItem in xSection: if xItem.tag != "item": @@ -432,7 +424,7 @@ class ProjectXMLReader: if item.get("type", "") == "TRASH": item["type"] = "ROOT" - projContent.append({ + content.append({ "name": itemName, "itemAttr": item, "metaAttr": meta, @@ -441,9 +433,8 @@ class ProjectXMLReader: return - def _parseStatusImport(self, xItem, sObject): - """Parse a status or importance entry. - """ + def _parseStatusImport(self, xItem: ET.Element, sObject: NWStatus): + """Parse a status or importance entry.""" for xEntry in xItem: if xEntry.tag == "entry": key = xEntry.attrib.get("key", None) @@ -454,7 +445,7 @@ class ProjectXMLReader: sObject.write(key, xEntry.text, (red, green, blue), count) return - def _parseDictKeyText(self, xItem): + def _parseDictKeyText(self, xItem: ET.Element) -> dict: """Parse a dictionary stored with key as an attribute and the value as the text porperty. """ @@ -474,12 +465,15 @@ class ProjectXMLReader: class ProjectXMLWriter: + """Core: Project XML Writer + + The project writer class will only write a file according to the + very latest spec. + """ def __init__(self, path): - self._path = Path(path) self._error = None - return ## @@ -487,16 +481,16 @@ class ProjectXMLWriter: ## @property - def error(self): + def error(self) -> Exception | None: + """Return the error status.""" return self._error ## # Methods ## - def write(self, projData, projContent, saveTime, editTime): - """Write the project data and content to the XML files. - """ + def write(self, data: NWProjectData, content: list, saveTime: float, editTime: int) -> bool: + """Write the project data and content to the XML files.""" tStart = time() logger.debug("Writing project XML") @@ -509,46 +503,46 @@ class ProjectXMLWriter: # Save Project Meta projAttr = { - "id": projData.uuid, - "saveCount": str(projData.saveCount), - "autoCount": str(projData.autoCount), + "id": data.uuid, + "saveCount": str(data.saveCount), + "autoCount": str(data.autoCount), "editTime": str(editTime), } xProject = ET.SubElement(xRoot, "project", attrib=projAttr) - self._packSingleValue(xProject, "name", projData.name) - self._packSingleValue(xProject, "title", projData.title) - self._packSingleValue(xProject, "author", projData.author) + self._packSingleValue(xProject, "name", data.name) + self._packSingleValue(xProject, "title", data.title) + self._packSingleValue(xProject, "author", data.author) # Save Project Settings xSettings = ET.SubElement(xRoot, "settings") - self._packSingleValue(xSettings, "doBackup", yesNo(projData.doBackup)) - self._packSingleValue(xSettings, "language", projData.language) - self._packSingleValue(xSettings, "spellChecking", projData.spellLang, attrib={ - "auto": yesNo(projData.spellCheck) + self._packSingleValue(xSettings, "doBackup", yesNo(data.doBackup)) + self._packSingleValue(xSettings, "language", data.language) + self._packSingleValue(xSettings, "spellChecking", data.spellLang, attrib={ + "auto": yesNo(data.spellCheck) }) - self._packDictKeyValue(xSettings, "lastHandle", projData.lastHandle) - self._packDictKeyValue(xSettings, "autoReplace", projData.autoReplace) - self._packDictKeyValue(xSettings, "titleFormat", projData.titleFormat) + self._packDictKeyValue(xSettings, "lastHandle", data.lastHandle) + self._packDictKeyValue(xSettings, "autoReplace", data.autoReplace) + self._packDictKeyValue(xSettings, "titleFormat", data.titleFormat) # Save Status/Importance xStatus = ET.SubElement(xSettings, "status") - for label, attrib in projData.itemStatus.pack(): + for label, attrib in data.itemStatus.pack(): self._packSingleValue(xStatus, "entry", label, attrib=attrib) xImport = ET.SubElement(xSettings, "importance") - for label, attrib in projData.itemImport.pack(): + for label, attrib in data.itemImport.pack(): self._packSingleValue(xImport, "entry", label, attrib=attrib) # Save Tree Content contAttr = { - "items": str(len(projContent)), - "novelWords": str(projData.currCounts[0]), - "notesWords": str(projData.currCounts[1]), + "items": str(len(content)), + "novelWords": str(data.currCounts[0]), + "notesWords": str(data.currCounts[1]), } xContent = ET.SubElement(xRoot, "content", attrib=contAttr) - for item in projContent: + for item in content: xItem = ET.SubElement(xContent, "item", attrib=item.get("itemAttr", {})) ET.SubElement(xItem, "meta", attrib=item.get("metaAttr", {})) xName = ET.SubElement(xItem, "name", attrib=item.get("nameAttr", {})) @@ -584,16 +578,16 @@ class ProjectXMLWriter: # Internal Functions ## - def _packSingleValue(self, xParent, name, value, attrib=None): - """Pack a single value into an XML element. - """ + def _packSingleValue( + self, xParent: ET.Element, name: str, value: str | None, attrib: dict | None = None + ): + """Pack a single value into an XML element.""" xItem = ET.SubElement(xParent, name, attrib=attrib or {}) xItem.text = str(value) or "" return - def _packDictKeyValue(self, xParent, name, data): - """Pack the entries of a dictionary into an XML element. - """ + def _packDictKeyValue(self, xParent: ET.Element, name: str, data: dict): + """Pack the entries of a dictionary into an XML element.""" xItem = ET.SubElement(xParent, name) for key, value in data.items(): if len(key) > 0: From 0704812376f5afc24f4835da22541f5752edd343 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 3 Jun 2023 18:13:02 +0200 Subject: [PATCH 52/74] Remove titleFormat from project XML and rename mock test file --- novelwriter/core/projectdata.py | 18 ------- novelwriter/core/projectxml.py | 6 --- tests/conftest.py | 2 +- tests/{mock.py => mocked.py} | 0 tests/test_base/test_base_common.py | 2 +- tests/test_base/test_base_config.py | 2 +- tests/test_base/test_base_error.py | 2 +- tests/test_base/test_base_init.py | 2 +- tests/test_core/test_core_coretools.py | 2 +- tests/test_core/test_core_docbuild.py | 2 +- tests/test_core/test_core_document.py | 2 +- tests/test_core/test_core_index.py | 2 +- tests/test_core/test_core_options.py | 2 +- tests/test_core/test_core_project.py | 2 +- tests/test_core/test_core_projectxml.py | 57 ++++----------------- tests/test_core/test_core_spellcheck.py | 2 +- tests/test_core/test_core_storage.py | 2 +- tests/test_core/test_core_tree.py | 2 +- tests/test_dialogs/test_dlg_wordlist.py | 2 +- tests/test_gui/test_gui_doceditor.py | 2 +- tests/test_gui/test_gui_docviewer.py | 2 +- tests/test_gui/test_gui_projtree.py | 2 +- tests/test_gui/test_gui_theme.py | 2 +- tests/test_tools/test_tools_writingstats.py | 2 +- 24 files changed, 30 insertions(+), 91 deletions(-) rename tests/{mock.py => mocked.py} (100%) diff --git a/novelwriter/core/projectdata.py b/novelwriter/core/projectdata.py index 50c48086..ea7415b6 100644 --- a/novelwriter/core/projectdata.py +++ b/novelwriter/core/projectdata.py @@ -170,11 +170,6 @@ class NWProjectData: """Return the autoreplace dictionary.""" return self._autoReplace - @property - def titleFormat(self): - """Delete""" - return self._titleFormat - @property def itemStatus(self) -> NWStatus: """Return the status settings object.""" @@ -209,10 +204,6 @@ class NWProjectData: """Retrieve the last used handle for a given component.""" return self._lastHandle.get(component, None) - def getTitleFormat(self, kind): - """Retrieve the title format string for a given kind of header.""" - return self._titleFormat.get(kind, "%title%") - ## # Setters ## @@ -337,13 +328,4 @@ class NWProjectData: self.theProject.setProjectChanged(True) return - def setTitleFormat(self, value): - """Set the title formats.""" - if isinstance(value, dict): - for key, entry in value.items(): - if key in self._titleFormat and isinstance(entry, str): - self._titleFormat[key] = simplified(entry) - self.theProject.setProjectChanged(True) - return - # END Class NWProjectData diff --git a/novelwriter/core/projectxml.py b/novelwriter/core/projectxml.py index 0726d7da..42f4e582 100644 --- a/novelwriter/core/projectxml.py +++ b/novelwriter/core/projectxml.py @@ -280,11 +280,6 @@ class ProjectXMLReader: data.setAutoReplace(self._parseDictKeyText(xItem)) else: # Pre 1.2 format data.setAutoReplace(self._parseDictTagText(xItem)) - elif xItem.tag == "titleFormat": - if self._version >= 0x0105: - data.setTitleFormat(self._parseDictKeyText(xItem)) - else: # Pre 1.4 format - data.setTitleFormat(self._parseDictTagText(xItem)) else: logger.warning("Ignored in XML", xItem.tag) @@ -523,7 +518,6 @@ class ProjectXMLWriter: }) self._packDictKeyValue(xSettings, "lastHandle", data.lastHandle) self._packDictKeyValue(xSettings, "autoReplace", data.autoReplace) - self._packDictKeyValue(xSettings, "titleFormat", data.titleFormat) # Save Status/Importance xStatus = ET.SubElement(xSettings, "status") diff --git a/tests/conftest.py b/tests/conftest.py index d333a1b9..c8c84095 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,7 +25,7 @@ import shutil from pathlib import Path -from mock import MockGuiMain +from mocked import MockGuiMain from tools import cleanProject from PyQt5.QtWidgets import QMessageBox diff --git a/tests/mock.py b/tests/mocked.py similarity index 100% rename from tests/mock.py rename to tests/mocked.py diff --git a/tests/test_base/test_base_common.py b/tests/test_base/test_base_common.py index 5559439e..c244cd05 100644 --- a/tests/test_base/test_base_common.py +++ b/tests/test_base/test_base_common.py @@ -25,7 +25,7 @@ import hashlib from pathlib import Path -from mock import causeOSError +from mocked import causeOSError from tools import writeFile from novelwriter.guimain import GuiMain diff --git a/tests/test_base/test_base_config.py b/tests/test_base/test_base_config.py index 0d986906..2a7e6130 100644 --- a/tests/test_base/test_base_config.py +++ b/tests/test_base/test_base_config.py @@ -25,7 +25,7 @@ import pytest from shutil import copyfile from pathlib import Path -from mock import causeOSError, MockApp +from mocked import causeOSError, MockApp from tools import cmpFiles, writeFile from novelwriter import CONFIG diff --git a/tests/test_base/test_base_error.py b/tests/test_base/test_base_error.py index d90e6eb1..d6a743c0 100644 --- a/tests/test_base/test_base_error.py +++ b/tests/test_base/test_base_error.py @@ -21,7 +21,7 @@ along with this program. If not, see . import pytest -from mock import causeException +from mocked import causeException from novelwriter.error import NWErrorMessage, exceptionHandler diff --git a/tests/test_base/test_base_init.py b/tests/test_base/test_base_init.py index a932dcfa..b6e09d15 100644 --- a/tests/test_base/test_base_init.py +++ b/tests/test_base/test_base_init.py @@ -23,7 +23,7 @@ import sys import pytest import logging -from mock import MockGuiMain +from mocked import MockGuiMain from novelwriter import CONFIG, main, logger diff --git a/tests/test_core/test_core_coretools.py b/tests/test_core/test_core_coretools.py index 585a9fae..c28354f8 100644 --- a/tests/test_core/test_core_coretools.py +++ b/tests/test_core/test_core_coretools.py @@ -25,7 +25,7 @@ import pytest from shutil import copyfile from zipfile import ZipFile -from mock import causeOSError +from mocked import causeOSError from tools import C, buildTestProject, cmpFiles, XML_IGNORE from novelwriter import CONFIG diff --git a/tests/test_core/test_core_docbuild.py b/tests/test_core/test_core_docbuild.py index 0dabad71..5953a10a 100644 --- a/tests/test_core/test_core_docbuild.py +++ b/tests/test_core/test_core_docbuild.py @@ -23,7 +23,7 @@ import pytest from shutil import copyfile -from mock import causeException, causeOSError +from mocked import causeException, causeOSError from novelwriter.core.buildsettings import BuildSettings from tools import ODT_IGNORE, cmpFiles diff --git a/tests/test_core/test_core_document.py b/tests/test_core/test_core_document.py index 58eb10fc..319e2ef8 100644 --- a/tests/test_core/test_core_document.py +++ b/tests/test_core/test_core_document.py @@ -21,7 +21,7 @@ along with this program. If not, see . import pytest -from mock import causeOSError +from mocked import causeOSError from tools import C, buildTestProject, readFile, writeFile from novelwriter.enum import nwItemClass, nwItemLayout diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py index c3838772..5fada997 100644 --- a/tests/test_core/test_core_index.py +++ b/tests/test_core/test_core_index.py @@ -24,7 +24,7 @@ import pytest from shutil import copyfile -from mock import causeException +from mocked import causeException from tools import C, buildTestProject, cmpFiles, writeFile from novelwriter.enum import nwItemClass, nwItemLayout diff --git a/tests/test_core/test_core_options.py b/tests/test_core/test_core_options.py index ce26dbe1..c241504e 100644 --- a/tests/test_core/test_core_options.py +++ b/tests/test_core/test_core_options.py @@ -22,7 +22,7 @@ along with this program. If not, see . import json import pytest -from mock import causeOSError +from mocked import causeOSError from novelwriter.constants import nwFiles from novelwriter.core.options import OptionState diff --git a/tests/test_core/test_core_project.py b/tests/test_core/test_core_project.py index 6425e4d8..05e4e6cc 100644 --- a/tests/test_core/test_core_project.py +++ b/tests/test_core/test_core_project.py @@ -26,7 +26,7 @@ from shutil import copyfile from pathlib import Path from zipfile import ZipFile -from mock import causeOSError +from mocked import causeOSError from tools import C, cmpFiles, writeFile, buildTestProject, XML_IGNORE from novelwriter import CONFIG diff --git a/tests/test_core/test_core_projectxml.py b/tests/test_core/test_core_projectxml.py index 35132107..d3108b72 100644 --- a/tests/test_core/test_core_projectxml.py +++ b/tests/test_core/test_core_projectxml.py @@ -25,8 +25,8 @@ import pytest from shutil import copyfile from datetime import datetime -from mock import causeOSError from tools import cmpFiles, writeFile +from mocked import causeOSError from novelwriter.core.item import NWItem from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState @@ -34,12 +34,16 @@ from novelwriter.core.projectdata import NWProjectData class MockProject: + """Fake project object.""" + def setProjectChanged(self, *a): + """Fake project method.""" pass @pytest.fixture(scope="function", autouse=True) def mockVersion(monkeypatch): + """Mock the version info to prevent diff from failing.""" monkeypatch.setattr("novelwriter.core.projectxml.__version__", "2.0-rc1") monkeypatch.setattr("novelwriter.core.projectxml.__hexversion__", "0x020000c1") return @@ -47,8 +51,7 @@ def mockVersion(monkeypatch): @pytest.mark.core def testCoreProjectXML_ReadCurrent(monkeypatch, tstPaths, fncPath): - """Test reading the current XML file format. - """ + """Test reading the current XML file format.""" refFile = tstPaths.filesDir / "nwProject-1.5.nwx" tstFile = tstPaths.outDir / "ProjectXML_ReadCurrent.nwx" xmlFile = fncPath / "nwProject-1.5.nwx" @@ -160,12 +163,6 @@ def testCoreProjectXML_ReadCurrent(monkeypatch, tstPaths, fncPath): assert data.getLastHandle("novelTree") == "7031beac91f75" assert data.getLastHandle("outline") == "7031beac91f75" - assert data.getTitleFormat("title") == "%title%" - assert data.getTitleFormat("chapter") == "Chapter %chw%: %title%" - assert data.getTitleFormat("unnumbered") == "%title%" - assert data.getTitleFormat("scene") == "Scene %ch%.%sc%: %title%" - assert data.getTitleFormat("section") == "" - assert data.itemStatus.name("sf12341") == "New" assert data.itemStatus.name("sf24ce6") == "Notes" assert data.itemStatus.name("sc24b8f") == "Started" @@ -286,12 +283,6 @@ def testCoreProjectXML_ReadLegacy10(tstPaths, fncPath, mockRnd): assert data.getLastHandle("novelTree") is None # Doesn't exist in 1.0 assert data.getLastHandle("outline") is None # Doesn't exist in 1.0 - assert data.getTitleFormat("title") == "%title%" - assert data.getTitleFormat("chapter") == "Chapter %ch%: %title%" - assert data.getTitleFormat("unnumbered") == "%title%" - assert data.getTitleFormat("scene") == "Scene %ch%.%sc%: %title%" - assert data.getTitleFormat("section") == "" - assert data.itemStatus.name("s000000") == "New" assert data.itemStatus.name("s000001") == "Notes" assert data.itemStatus.name("s000002") == "Started" @@ -388,8 +379,7 @@ def testCoreProjectXML_ReadLegacy10(tstPaths, fncPath, mockRnd): @pytest.mark.core def testCoreProjectXML_ReadLegacy11(tstPaths, fncPath, mockRnd): - """Test reading the version 1.1 XML file format. - """ + """Test reading the version 1.1 XML file format.""" refFile = tstPaths.filesDir / "nwProject-1.1.nwx" xmlFile = fncPath / "nwProject-1.1.nwx" outFile = fncPath / "nwProject.nwx" @@ -428,12 +418,6 @@ def testCoreProjectXML_ReadLegacy11(tstPaths, fncPath, mockRnd): assert data.getLastHandle("novelTree") is None # Doesn't exist in 1.1 assert data.getLastHandle("outline") is None # Doesn't exist in 1.1 - assert data.getTitleFormat("title") == "%title%" - assert data.getTitleFormat("chapter") == "Chapter %ch%: %title%" - assert data.getTitleFormat("unnumbered") == "%title%" - assert data.getTitleFormat("scene") == "Scene %ch%.%sc%: %title%" - assert data.getTitleFormat("section") == "" - assert data.itemStatus.name("s000000") == "New" assert data.itemStatus.name("s000001") == "Notes" assert data.itemStatus.name("s000002") == "Started" @@ -530,8 +514,7 @@ def testCoreProjectXML_ReadLegacy11(tstPaths, fncPath, mockRnd): @pytest.mark.core def testCoreProjectXML_ReadLegacy12(tstPaths, fncPath, mockRnd): - """Test reading the version 1.2 XML file format. - """ + """Test reading the version 1.2 XML file format.""" refFile = tstPaths.filesDir / "nwProject-1.2.nwx" xmlFile = fncPath / "nwProject-1.2.nwx" outFile = fncPath / "nwProject.nwx" @@ -570,12 +553,6 @@ def testCoreProjectXML_ReadLegacy12(tstPaths, fncPath, mockRnd): assert data.getLastHandle("novelTree") is None # Doesn't exist in 1.2 assert data.getLastHandle("outline") is None # Doesn't exist in 1.2 - assert data.getTitleFormat("title") == "%title%" - assert data.getTitleFormat("chapter") == "Chapter %chw%: %title%" - assert data.getTitleFormat("unnumbered") == "%title%" - assert data.getTitleFormat("scene") == "Scene %ch%.%sc%: %title%" - assert data.getTitleFormat("section") == "" - assert data.itemStatus.name("s000000") == "New" assert data.itemStatus.name("s000001") == "Notes" assert data.itemStatus.name("s000002") == "Started" @@ -675,8 +652,7 @@ def testCoreProjectXML_ReadLegacy12(tstPaths, fncPath, mockRnd): @pytest.mark.core def testCoreProjectXML_ReadLegacy13(tstPaths, fncPath, mockRnd): - """Test reading the version 1.3 XML file format. - """ + """Test reading the version 1.3 XML file format.""" refFile = tstPaths.filesDir / "nwProject-1.3.nwx" xmlFile = fncPath / "nwProject-1.3.nwx" outFile = fncPath / "nwProject.nwx" @@ -715,12 +691,6 @@ def testCoreProjectXML_ReadLegacy13(tstPaths, fncPath, mockRnd): assert data.getLastHandle("novelTree") is None # Doesn't exist in 1.3 assert data.getLastHandle("outline") is None # Doesn't exist in 1.3 - assert data.getTitleFormat("title") == "%title%" - assert data.getTitleFormat("chapter") == "Chapter %chw%: %title%" - assert data.getTitleFormat("unnumbered") == "%title%" - assert data.getTitleFormat("scene") == "Scene %ch%.%sc%: %title%" - assert data.getTitleFormat("section") == "" - assert data.itemStatus.name("s000000") == "New" assert data.itemStatus.name("s000001") == "Notes" assert data.itemStatus.name("s000002") == "Started" @@ -820,8 +790,7 @@ def testCoreProjectXML_ReadLegacy13(tstPaths, fncPath, mockRnd): @pytest.mark.core def testCoreProjectXML_ReadLegacy14(tstPaths, fncPath, mockRnd): - """Test reading the version 1.4 XML file format. - """ + """Test reading the version 1.4 XML file format.""" refFile = tstPaths.filesDir / "nwProject-1.4.nwx" xmlFile = fncPath / "nwProject-1.4.nwx" outFile = fncPath / "nwProject.nwx" @@ -860,12 +829,6 @@ def testCoreProjectXML_ReadLegacy14(tstPaths, fncPath, mockRnd): assert data.getLastHandle("novelTree") is None # Doesn't exist in 1.3 assert data.getLastHandle("outline") is None # Doesn't exist in 1.3 - assert data.getTitleFormat("title") == "%title%" - assert data.getTitleFormat("chapter") == "Chapter %chw%: %title%" - assert data.getTitleFormat("unnumbered") == "%title%" - assert data.getTitleFormat("scene") == "Scene %ch%.%sc%: %title%" - assert data.getTitleFormat("section") == "" - assert data.itemStatus.name("sf12341") == "New" assert data.itemStatus.name("sf24ce6") == "Notes" assert data.itemStatus.name("sc24b8f") == "Started" diff --git a/tests/test_core/test_core_spellcheck.py b/tests/test_core/test_core_spellcheck.py index b13e7100..1782adb4 100644 --- a/tests/test_core/test_core_spellcheck.py +++ b/tests/test_core/test_core_spellcheck.py @@ -22,7 +22,7 @@ along with this program. If not, see . import sys import pytest -from mock import causeOSError +from mocked import causeOSError from tools import readFile, writeFile from novelwriter.core.spellcheck import FakeEnchant, NWSpellEnchant diff --git a/tests/test_core/test_core_storage.py b/tests/test_core/test_core_storage.py index fe1dedc4..5baa8cf0 100644 --- a/tests/test_core/test_core_storage.py +++ b/tests/test_core/test_core_storage.py @@ -22,7 +22,7 @@ along with this program. If not, see . from zipfile import ZipFile import pytest -from mock import causeOSError +from mocked import causeOSError from tools import C, buildTestProject, writeFile from novelwriter import CONFIG diff --git a/tests/test_core/test_core_tree.py b/tests/test_core/test_core_tree.py index 80a6e7cc..ee9ce687 100644 --- a/tests/test_core/test_core_tree.py +++ b/tests/test_core/test_core_tree.py @@ -24,7 +24,7 @@ import random from pathlib import Path -from mock import causeOSError +from mocked import causeOSError from tools import readFile from novelwriter.enum import nwItemClass, nwItemType, nwItemLayout diff --git a/tests/test_dialogs/test_dlg_wordlist.py b/tests/test_dialogs/test_dlg_wordlist.py index fef4b4a3..8781a82d 100644 --- a/tests/test_dialogs/test_dlg_wordlist.py +++ b/tests/test_dialogs/test_dlg_wordlist.py @@ -25,7 +25,7 @@ from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QDialog, QAction from tools import buildTestProject, writeFile, readFile, getGuiItem -from mock import causeOSError +from mocked import causeOSError from novelwriter.constants import nwFiles from novelwriter.dialogs.wordlist import GuiWordList diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index 34f9dfd1..1dc3610b 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -21,7 +21,7 @@ along with this program. If not, see . import pytest -from mock import causeOSError +from mocked import causeOSError from tools import C, buildTestProject from PyQt5.QtCore import Qt diff --git a/tests/test_gui/test_gui_docviewer.py b/tests/test_gui/test_gui_docviewer.py index 2bd7ad7a..bb3437dd 100644 --- a/tests/test_gui/test_gui_docviewer.py +++ b/tests/test_gui/test_gui_docviewer.py @@ -21,7 +21,7 @@ along with this program. If not, see . import pytest -from mock import causeException +from mocked import causeException from PyQt5.QtCore import Qt, QUrl from PyQt5.QtGui import QTextCursor diff --git a/tests/test_gui/test_gui_projtree.py b/tests/test_gui/test_gui_projtree.py index dbaeb09f..92b68e9a 100644 --- a/tests/test_gui/test_gui_projtree.py +++ b/tests/test_gui/test_gui_projtree.py @@ -21,7 +21,7 @@ along with this program. If not, see . import pytest -from mock import causeOSError +from mocked import causeOSError from tools import C, buildTestProject from PyQt5.QtCore import Qt diff --git a/tests/test_gui/test_gui_theme.py b/tests/test_gui/test_gui_theme.py index a4aa6b2a..3ba5f405 100644 --- a/tests/test_gui/test_gui_theme.py +++ b/tests/test_gui/test_gui_theme.py @@ -24,7 +24,7 @@ import pytest from pathlib import Path from configparser import ConfigParser -from mock import causeOSError +from mocked import causeOSError from tools import writeFile from PyQt5.QtGui import QIcon, QPalette, QPixmap diff --git a/tests/test_tools/test_tools_writingstats.py b/tests/test_tools/test_tools_writingstats.py index 7cf832f6..af2e4f0c 100644 --- a/tests/test_tools/test_tools_writingstats.py +++ b/tests/test_tools/test_tools_writingstats.py @@ -22,7 +22,7 @@ along with this program. If not, see . import json import pytest -from mock import causeOSError +from mocked import causeOSError from tools import getGuiItem, writeFile, buildTestProject from PyQt5.QtCore import Qt From 8ef3b3e09a4b28fd20a4fec9d34172ef0e9379bc Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 3 Jun 2023 18:37:41 +0200 Subject: [PATCH 53/74] Add project file revision number, and update all test reference files --- docs/FileFormatSpec-1.5.fodt | 1272 +++++++++-------- docs/source/fileformatspec15.pdf | Bin 307438 -> 319906 bytes novelwriter/core/projectxml.py | 17 +- sample/nwProject.nwx | 13 +- tests/files/nwProject-1.5.nwx | 9 +- tests/lipsum/nwProject.nwx | 15 +- .../coreProject_NewFileFolder_nwProject.nwx | 9 +- .../coreProject_NewRoot_nwProject.nwx | 9 +- .../coreTools_NewCustomA_nwProject.nwx | 9 +- .../coreTools_NewCustomB_nwProject.nwx | 9 +- .../coreTools_NewMinimal_nwProject.nwx | 9 +- .../guiEditor_Main_Final_nwProject.nwx | 9 +- .../guiEditor_Main_Initial_nwProject.nwx | 9 +- tests/reference/projectXML_ReadLegacy10.nwx | 9 +- tests/reference/projectXML_ReadLegacy11.nwx | 9 +- tests/reference/projectXML_ReadLegacy12.nwx | 9 +- tests/reference/projectXML_ReadLegacy13.nwx | 9 +- tests/reference/projectXML_ReadLegacy14.nwx | 9 +- 18 files changed, 733 insertions(+), 701 deletions(-) diff --git a/docs/FileFormatSpec-1.5.fodt b/docs/FileFormatSpec-1.5.fodt index 67ceb247..a57c8627 100644 --- a/docs/FileFormatSpec-1.5.fodt +++ b/docs/FileFormatSpec-1.5.fodt @@ -1,29 +1,31 @@ - - Veronica Olsen2022-11-05T18:25:44.8874731592023-02-09T13:03:44.196703858Veronica OlsenPT3H48M55S42LibreOffice/7.0.4.2$Linux_X86_64 LibreOffice_project/00$Build-2 + + Veronica Olsen2022-11-05T18:25:44.8874731592023-06-03T18:35:47.268848685Veronica OlsenPT4H19S53LibreOffice/7.4.5.1$Linux_X86_64 LibreOffice_project/40$Build-1 - 9825 + 0 0 - 35909 - 19657 + 35645 + 20394 true false view2 - 23216 - 176844 + 15145 + 7216 0 - 9825 - 35907 - 29480 + 0 + 35643 + 20392 0 1 false 140 false + false + false false @@ -47,12 +49,14 @@ false true false + false false false true true true false + false false false false @@ -70,6 +74,7 @@ 0 1 true + false high-resolution true @@ -79,16 +84,18 @@ false true false + false true true false true true - 695346 + 805029 true false + true true 0 @@ -98,12 +105,15 @@ true false true + 0 false false false false true + false false + false false false @@ -137,26 +147,26 @@ - + + - - - + + - + - + - + @@ -167,7 +177,7 @@ - + @@ -176,15 +186,15 @@ - + - + - + @@ -238,53 +248,57 @@ + + + + - + - + - + - + - + - + - + - + - + - + @@ -355,6 +369,15 @@ + + + + + + + + + @@ -536,13 +559,9 @@ - - - - - - - + + + @@ -1012,202 +1031,229 @@ - + + - + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - + + - + - - - - - - - - - - - - - - - - + - + + + + + + + + + + + + + + + + - - + + - - + + - - + + - - + + - - + + + - - + + - - + + - - + + + - - + + - - + + - - + + - + - - - - - - - - - - - - - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - - + + + + + + + + + + + + + + - - + + - + + + + + + + - - - - - - - - + + + + + + + + - + - + - - - - - - - + + + + + + + - - - - - - + + + + + + - - + + - - + + + + + + + + + + + + + + + + + + + + + + + @@ -1240,66 +1286,130 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + - + @@ -1310,829 +1420,845 @@ - novelWriter - Project File Format 1.5 Specification - - This document covers the file format specification for the 1.5 file format for novelWriter project files. See the documentation1 - See: https://novelwriter.readthedocs.io/ for a full description of the app’s functionality. - The target audience of this document is developers who intend to write a tool or script that generates valid novelWriter project files from data from other applications, or for templating purposes. - Note that flags are generally written out as yes/no values, but also true/false and on/off are understood by the application. The None value that occasionally occurs in actual project files is the text representation of the Python None value, and indicates that there is no value set. + novelWriter + Project File Format 1.5 Specification + + This document covers the file format specification for the 1.5 file format for novelWriter project files. See the documentation1 + See: https://docs.novelwriter.io for a full description of the app’s functionality. The file format was intrioduced in Release 2.0. + The target audience of this document is developers who intend to write a tool or script that generates valid novelWriter project files from data from other applications, or for templating purposes. + Note that flags are generally written out as yes/no values, but also true/false and on/off are understood by the application. The None value that occasionally occurs in actual project files is the text representation of the Python None value, and indicates that there is no value set. + Revisions + + + From Release 2.1 Beta 1: Removed the titleFormat node from settings. + + XML Root - Tag: novelWriterXML - The root tag of a novelWriter project file must be named “novelWriterXML”. This value is case sensitive. The fileVersion attribute must also be set to the appropriate format version in order to be parsed correctly. - Attributes + Tag: novelWriterXML + The root tag of a novelWriter project file must be named “novelWriterXML”. This value is case sensitive. The fileVersion attribute must also be set to the appropriate format version in order to be parsed correctly. + Attributes - Name + Name - Required + Required - Description + Description - + - appVersion + appVersion - No + No - A string representation of the novelWriter version used to write the file. The value is not required, but is used to report to the user when the file format is converted. + A string representation of the novelWriter version used to write the file. The value is not required, but is used to report to the user when the file format is converted. - - - hexVersion + + + hexVersion - No + No - A hex representation of the novelWriter version used to write the file. The value is used to check if the project is opened by a lower version than was used to write it, which issues a warning. Defaults to 0x0. + A hex representation of the novelWriter version used to write the file. The value is used to check if the project is opened by a lower version than was used to write it, which issues a warning. Defaults to 0x0. - - - fileVersion + + + fileVersion - Yes + Yes - The file format version used when writing the project file. This determines how the file is parsed, so it is important that it is set correctly. Valid values are “1.0”, “1.1”, “1.2”, “1.3”, “1.4”, and “1.5”. + The file format version used when writing the project file. This determines how the file is parsed, so it is important that it is set correctly. Valid values are “1.0”, “1.1”, “1.2”, “1.3”, “1.4”, and “1.5”. - - - timeStamp + + + fileRevision - - No + + No - - The ISO 8601 timestamp of when the file was saved. This information is not used by the parser, and is purely for debugging. + + The revision of the file format used for non-breaking changes. This is an interger value. + + + + + timeStamp + + + No + + + The ISO 8601 timestamp of when the file was saved. This information is not used by the parser, and is purely for debugging. - Example - <novelWriterXML appVersion="2.0-rc1" hexVersion="0x020000c1" - fileVersion="1.5" timeStamp="2022-11-05 17:46:51"> - - Project Node - Tag: project - The project section is a level one node under the root. It stores the primary settings for the project. The only required values are the id attribute and the name value. All other entries are set to default values during parsing if ommitted from the XML. - Attributes + Example + <novelWriterXML appVersion="2.0-rc1" hexVersion="0x020000c1" + fileVersion="1.5" fileRevision="1" + timeStamp="2022-11-05 17:46:51"> + Project Node + Tag: project + The project section is a level one node under the root. It stores the primary settings for the project. The only required values are the id attribute and the name value. All other entries are set to default values during parsing if ommitted from the XML. + Attributes - Name + Name - Required + Required - Description + Description - + - id + id - Yes + Yes - A UUID that is unique for the project and does not change during the lifetime of the project. It is used by novelWriter to keep track of temporary files and to associate cross-project settings with specific projects without relying on the project name. + A UUID that is unique for the project and does not change during the lifetime of the project. It is used by novelWriter to keep track of temporary files and to associate cross-project settings with specific projects without relying on the project name. - - - saveCount + + + saveCount - No + No - A number representing the number of times the project file has been written as a result of user interaction with the application. + A number representing the number of times the project file has been written as a result of user interaction with the application. - - - autoCount + + + autoCount - No + No - A number representing the number of times the project file has been written as a result of internal timed automatic operations of the application. + A number representing the number of times the project file has been written as a result of internal timed automatic operations of the application. - + - editTime + editTime - No + No - A number representing the accumulated time in seconds the project has been open. + A number representing the accumulated time in seconds the project has been open. - Values + Values - Name + Name - Required + Required - Description + Description - + - name + name - Yes + Yes - The name the user has given to the project. + The name the user has given to the project. - - - title + + + title - No + No - The title the user has given to the project, which may differ from the project name. The value is used in some places in the user interface, and can be added to manuscript builds. + The title the user has given to the project, which may differ from the project name. The value is used in some places in the user interface, and can be added to manuscript builds. - - - author + + + author - No + No - The name of the novel author or authors. The value is used in some places in the user interface, and can be added to manuscript builds. + The name of the novel author or authors. The value is used in some places in the user interface, and can be added to manuscript builds. - Example - <project id="e2be99af-f9bf-4403-857a-c3d1ac25abea" - saveCount="5" autoCount="10" editTime="1000"> - <name>Sample Project</name> - <title>Sample Project</title> - <author>Jane Smith</author> - </project> - - Settings Node - Tag: settings - The settings section is a level one node under the root. It stores the various runtime values for the project, and the settings available from the Project Settings panel. None of these settings are required, and if missing, will be set to default values. The entire settings section can thus be ommitted. - Attributes - None - Values + Example + <project id="e2be99af-f9bf-4403-857a-c3d1ac25abea" + saveCount="5" autoCount="10" editTime="1000"> + <name>Sample Project</name> + <title>Sample Project</title> + <author>Jane Smith</author> + </project> + + Settings Node + Tag: settings + The settings section is a level one node under the root. It stores the various runtime values for the project, and the settings available from the Project Settings panel. None of these settings are required, and if missing, will be set to default values. The entire settings section can thus be ommitted. + Attributes + None + Values - Name + Name - Required + Required - Description + Description - + - doBackup + doBackup - No + No - User-controlled setting for whether or not to run backup when the project is closed. + User-controlled setting for whether or not to run backup when the project is closed. - + - language + language - No + No - The language used when building the manuscript. The setting is controlled from the Build Novel Project tool. + The language used when building the manuscript. The setting is controlled from the Build Novel Project tool. - + - spellChecking + spellChecking - No + No - The spell checking language to use for this project if it differs from the default spell checking language for the application. - - Attribute auto: A flag determining whether the spell checking is automatic or not. + The spell checking language to use for this project if it differs from the default spell checking language for the application. + + Attribute auto: A flag determining whether the spell checking is automatic or not. - + - lastHandle + lastHandle - No + No - An auto-generated list of the last documents open in the editor and viewer, and which novel root folder was last viewed in the Novel Tree and Outline View. These are saved as key/value pairs. See separate section for how they are stored as XML. + An auto-generated list of the last documents open in the editor and viewer, and which novel root folder was last viewed in the Novel Tree and Outline View. These are saved as key/value pairs. See separate section for how they are stored as XML. - + - autoReplace + autoReplace - No + No - The entries of the auto-replace feature available from Project Settings. These are saved as key/value pairs. See separate section for how they are stored as XML. + The entries of the auto-replace feature available from Project Settings. These are saved as key/value pairs. See separate section for how they are stored as XML. - + - titleFormat + titleFormat - - No + + No - - The formatting specifications set by the user in the Build Novel Project tool for formatting headers in the manuscript. These are saved as key/value pairs. See separate section for how they are stored as XML. + + Removed in Revision 1. - + - status + status - No + No - The status labels as defined by the user in Project Settings. These are saved as special key/value pars. See separate section for how they are stored as XML. + The status labels as defined by the user in Project Settings. These are saved as special key/value pars. See separate section for how they are stored as XML. - + - importance + importance - No + No - The importance labels as defined by the user in Project Settings. These are saved as special key/value pars. See separate section for how they are stored as XML. + The importance labels as defined by the user in Project Settings. These are saved as special key/value pars. See separate section for how they are stored as XML. - Example - <settings> - <doBackup>yes</doBackup> - <language>en_GB</language> - <spellChecking auto="yes">en_GB</spellChecking> - <lastHandle /> - <autoReplace /> - <titleFormat /> - <status /> - <importance /> - </settings> - - Key/Value Nodes - The key/value nodes are used for lastHandle, autoReplace and titleFormat settings. The lookup key is stored as an attribute, and the value is the text of the node. - Attributes + Example + <settings> + <doBackup>yes</doBackup> + <language>en_GB</language> + <spellChecking auto="yes">en_GB</spellChecking> + <lastHandle /> + <autoReplace /> + <status /> + <importance /> + </settings> + + Key/Value Nodes + The key/value nodes are used for lastHandle, autoReplace and titleFormat settings. The lookup key is stored as an attribute, and the value is the text of the node. + Attributes - Name + Name - Required + Required - Description + Description - + - key + key - Yes + Yes - The lookup key. + The lookup key. - Example - <lastHandle> - <entry key="editor">636b6aa9b697b</entry> - <entry key="viewer">636b6aa9b697b</entry> - <entry key="novelTree">7031beac91f75</entry> - <entry key="outline">7031beac91f75</entry> - </lastHandle> - Status/Importance Key/Value Nodes - The status and importance settings are stored as key/value nodes with additional data attributes. The label of the status or importance setting is saved as the node text value. - Attributes + Example + <lastHandle> + <entry key="editor">636b6aa9b697b</entry> + <entry key="viewer">636b6aa9b697b</entry> + <entry key="novelTree">7031beac91f75</entry> + <entry key="outline">7031beac91f75</entry> + </lastHandle> + Status/Importance Key/Value Nodes + The status and importance settings are stored as key/value nodes with additional data attributes. The label of the status or importance setting is saved as the node text value. + Attributes - Name + Name - Required + Required - Description + Description - + - key + key - Yes + Yes - The lookup key. For status labels, it must consist of an “s” followed by six hexadecimal numbers. For importance labels it must consist of an “i” followed by six hexadecimal numbers. Each key string must be unique. Internally, they are random numbers, but also sequential values are valid. + The lookup key. For status labels, it must consist of an “s” followed by six hexadecimal numbers. For importance labels it must consist of an “i” followed by six hexadecimal numbers. Each key string must be unique. Internally, they are random numbers, but also sequential values are valid. - - - count + + + count - No + No - A record of the number of times each label is used in the project content. + A record of the number of times each label is used in the project content. - - - red + + + red - Yes + Yes - A number between 0 and 255 representing the red component of the label colour. + A number between 0 and 255 representing the red component of the label colour. - + - green + green - Yes + Yes - A number between 0 and 255 representing the green component of the label colour. + A number between 0 and 255 representing the green component of the label colour. - + - blue + blue - Yes + Yes - A number between 0 and 255 representing the blue component of the label colour. + A number between 0 and 255 representing the blue component of the label colour. - Example - <status> - <entry key="sf12341" count="4" - red="100" green="100" blue="100">New</entry> - <entry key="sd51c5b" count="0" - red="193" green="129" blue="0">Draft</entry> - <entry key="s78ea90" count="1" - red="58" green="180" blue="58">Finished</entry> - </status> - <importance> - <entry key="ia857f0" count="5" - red="100" green="100" blue="100">None</entry> - <entry key="icfb3a5" count="2" - red="0" green="122" blue="188">Minor</entry> - <entry key="i2d7a54" count="2" - red="21" green="0" blue="180">Major</entry> - </importance> - - Content Node - Tag: content - The content section is a level one node under the root. It stores all the project items of the project. The items are stored in the order in which they appear in the project tree, so altering the order will affect the project structure. The attribute with the order number is not used when processing the data. It is written to the XML for debugging purposes. - Attributes + Example + <status> + <entry key="sf12341" count="4" + red="100" green="100" blue="100">New</entry> + <entry key="sd51c5b" count="0" + red="193" green="129" blue="0">Draft</entry> + <entry key="s78ea90" count="1" + red="58" green="180" blue="58">Finished</entry> + </status> + <importance> + <entry key="ia857f0" count="5" + red="100" green="100" blue="100">None</entry> + <entry key="icfb3a5" count="2" + red="0" green="122" blue="188">Minor</entry> + <entry key="i2d7a54" count="2" + red="21" green="0" blue="180">Major</entry> + </importance> + + Content Node + Tag: content + The content section is a level one node under the root. It stores all the project items of the project. The items are stored in the order in which they appear in the project tree, so altering the order will affect the project structure. The attribute with the order number is not used when processing the data. It is written to the XML for debugging purposes. + Attributes - Name + Name - Required + Required - Description + Description - - - items + + + items - No + No - The number of project items in the content section. + The number of project items in the content section. - - - novelWords + + + novelWords - No + No - The number of words in total for all document nodes with layout DOCUMENT. The value defaults to 0, but this number forms the basis of computing writing statistics during a session, so the number should be properly set to ensure correct statistics. + The number of words in total for all document nodes with layout DOCUMENT. The value defaults to 0, but this number forms the basis of computing writing statistics during a session, so the number should be properly set to ensure correct statistics. - + - notesWords + notesWords - No + No - The number of words in total for all document nodes with layout NOTE. The value defaults to 0, but this number forms the basis of computing writing statistics during a session, so the number should be properly set to ensure correct statistics. + The number of words in total for all document nodes with layout NOTE. The value defaults to 0, but this number forms the basis of computing writing statistics during a session, so the number should be properly set to ensure correct statistics. - Values + Values - Name + Name - Required + Required - Description + Description - + - item + item - No + No - A node representing a project item. + A node representing a project item. - Example - <content items="27" novelWords="954" notesWords="409"> - <item handle="7031beac91f75" parent="None" root="7031beac91f75" - order="0" type="ROOT" class="NOVEL"> - <meta expanded="yes"/> - <name status="sc24b8f" import="ia857f0">Novel</name> - </item> - ... - </content> - Item Nodes - The item nodes make up the actual project content of the project. Each node represents either a root folder, a regular folder, or a document. Each node has a type, class and layout setting that determine its category. Each item is given a handle that is a random hexadecimal string of length 13. - For the items that are document files, the handle corresponds to its expected filename. It is therefore important that they match in generated projects. Each document node is expected to correspond to a file in the contents folder of the project named “content/7031beac91f75.nwd” for the item handle “7031beac91f75”. If there is no such file, the document item is assumed to contain no text, and a file is created when the user tries to save text to it. - Each item node has a meta data node and a name node. The meta data node contains only collected information, and is thus not strictly required. However, an accurate word count and setting the correct icon in the project tree depend on these values. These values are set by the indexer class, so rebuilding the index should restore the data. - The name node contains the primary user defined settings for an item, and is required for the entry to be valid. - - Attributes + Example + <content items="27" novelWords="954" notesWords="409"> + <item handle="7031beac91f75" parent="None" root="7031beac91f75" + order="0" type="ROOT" class="NOVEL"> + <meta expanded="yes"/> + <name status="sc24b8f" import="ia857f0">Novel</name> + </item> + ... + </content> + Item Nodes + The item nodes make up the actual project content of the project. Each node represents either a root folder, a regular folder, or a document. Each node has a type, class and layout setting that determine its category. Each item is given a handle that is a random hexadecimal string of length 13. + For the items that are document files, the handle corresponds to its expected filename. It is therefore important that they match in generated projects. Each document node is expected to correspond to a file in the contents folder of the project named “content/7031beac91f75.nwd” for the item handle “7031beac91f75”. If there is no such file, the document item is assumed to contain no text, and a file is created when the user tries to save text to it. + Each item node has a meta data node and a name node. The meta data node contains only collected information, and is thus not strictly required. However, an accurate word count and setting the correct icon in the project tree depend on these values. These values are set by the indexer class, so rebuilding the index should restore the data. + The name node contains the primary user defined settings for an item, and is required for the entry to be valid. + + Attributes - Name + Name - Required + Required - Description + Description - + - handle + handle - Yes + Yes - The 13 value hexadecimal string that represents the item in the project. This is the primary identifier of a project item, and is required. It must be unique withing a project. Internally, it is generated as a random value, but it can also be sequential. + The 13 value hexadecimal string that represents the item in the project. This is the primary identifier of a project item, and is required. It must be unique withing a project. Internally, it is generated as a random value, but it can also be sequential. - - - parent + + + parent - - Yes + + Yes - - The handle of the parent item in the tree. This value should only be None for root folders. All other items must have a parent handle set. If the parent handle is None for an item that isn’t a root folder, the item will be treated as orphaned during project loading. + + The handle of the parent item in the tree. This value should only be None for root folders. All other items must have a parent handle set. If the parent handle is None for an item that isn’t a root folder, the item will be treated as orphaned during project loading. - - - root + + + root - - No + + No - - The handle of the top of its hierarchy of parent items. That is, the root folder which it ultimately sits under. If the attribute is not set, it will be computed during loading. It is saved to the XML file for efficiency reasons. + + The handle of the top of its hierarchy of parent items. That is, the root folder which it ultimately sits under. If the attribute is not set, it will be computed during loading. It is saved to the XML file for efficiency reasons. - - - order + + + order - - No + + No - - The numerical order of the item under its parent item. This value is not used during the loading process as the physical item order in the content node is used instead. It is primarily saved to the XML for debugging purposes. + + The numerical order of the item under its parent item. This value is not used during the loading process as the physical item order in the content node is used instead. It is primarily saved to the XML for debugging purposes. - - - type + + + type - - Yes + + Yes - - The item type of the item node. Allowed values are ROOT, FOLDER and FILE. + + The item type of the item node. Allowed values are ROOT, FOLDER and FILE. - - - class + + + class - - Yes + + Yes - - The item class of the item node. There are a number of item classes available in the app, all corresponding to a specific type of root folder. Each item in a root folder should have the same class set as the root folder itself. The attribute is not strictly required for items that aren’t root folders as it will be automatically set to match during loading. Allowed values are NOVEL, PLOT, CHARACTER, WORLD, TIMELINE, OBJECT, ENTITY, CUSTOM, ARCHIVE and TRASH. + + The item class of the item node. There are a number of item classes available in the app, all corresponding to a specific type of root folder. Each item in a root folder should have the same class set as the root folder itself. The attribute is not strictly required for items that aren’t root folders as it will be automatically set to match during loading. Allowed values are NOVEL, PLOT, CHARACTER, WORLD, TIMELINE, OBJECT, ENTITY, CUSTOM, ARCHIVE and TRASH. - + - layout + layout - Yes + Yes - The item layout of the item node. Allowed values are DOCUMENT and NOTE. This is the attribute that determines if a FILE type item is a Novel Document or a Project Note. + The item layout of the item node. Allowed values are DOCUMENT and NOTE. This is the attribute that determines if a FILE type item is a Novel Document or a Project Note. - Values + Values - Name + Name - Required + Required - Description + Description - + - meta + meta - No + No - A node of meta data attributes for the item. + A node of meta data attributes for the item. - + - name + name - Yes + Yes - A node of user settings for the item. + A node of user settings for the item. - Meta Nodes - The meta data collected for the current item. This data either represents the item’s last state in the app, or consists of data collected by the indexer. It can be restored by rebuilding the index, so the data is not essential. - Attributes + Meta Nodes + The meta data collected for the current item. This data either represents the item’s last state in the app, or consists of data collected by the indexer. It can be restored by rebuilding the index, so the data is not essential. + Attributes - Name + Name - Required + Required - Description + Description - + - expanded + expanded - No + No - Whether the tree node in the project tree was expanded or collapsed during the last session. Applies to all item types, but is ignored for items without child items. + Whether the tree node in the project tree was expanded or collapsed during the last session. Applies to all item types, but is ignored for items without child items. - - - heading + + + heading - - No + + No - - The heading level of the first heading of the text of the item. Only applies to FILE item types. Allowed values are H0, H1, H2, H3, and H4. Other values are reset to H0. + + The heading level of the first heading of the text of the item. Only applies to FILE item types. Allowed values are H0, H1, H2, H3, and H4. Other values are reset to H0. - - - charCount + + + charCount - - No + + No - - The number of characters in the text of the item. Only applies to FILE item types. + + The number of characters in the text of the item. Only applies to FILE item types. - - - wordCount + + + wordCount - - No + + No - - The number of words in the text of the item. Only applies to FILE item types. + + The number of words in the text of the item. Only applies to FILE item types. - - - paraCount + + + paraCount - - No + + No - - The number of paragraphs in the text of the item. Only applies to FILE item types. + + The number of paragraphs in the text of the item. Only applies to FILE item types. - - - cursorPos + + + cursorPos - - No + + No - - The last cursor position in the text of the item from the last editing session. Only applies to FILE item types. The value is used to restore the cursor position when the document is opened in the editor. + + The last cursor position in the text of the item from the last editing session. Only applies to FILE item types. The value is used to restore the cursor position when the document is opened in the editor. - Name Nodes - The name node contains information about an item that is set by the user. Including its label, which is the text value of the node. This is the label that is displayed in the project tree. - Attributes + Name Nodes + The name node contains information about an item that is set by the user. Including its label, which is the text value of the node. This is the label that is displayed in the project tree. + Attributes - Name + Name - Required + Required - Description + Description - + - status + status - No + No - The ID of the status label that has been set for this item. Defaults to the first status item defined in the status section of the settings node. + The ID of the status label that has been set for this item. Defaults to the first status item defined in the status section of the settings node. - - - import + + + import - - No + + No - - The ID of the importance label that has been set for this item. Defaults to the first importance item defined in the importance section of the settings node. + + The ID of the importance label that has been set for this item. Defaults to the first importance item defined in the importance section of the settings node. - - - active + + + active - - No + + No - - The active/inactive status of the document. Only applies to FILE item types. + + The active/inactive status of the document. Only applies to FILE item types. - Example - <item handle="53b69b83cdafc" parent="7031beac91f75" root="7031beac91f75" - order="0" type="FILE" class="NOVEL" layout="DOCUMENT"> - <meta expanded="no" heading="H1" charCount="93" wordCount="19" - paraCount="2" cursorPos="119"/> - <name status="sc24b8f" import="ia857f0" active="yes">Title Page</name> - </item> + Example + <item handle="53b69b83cdafc" parent="7031beac91f75" root="7031beac91f75" + order="0" type="FILE" class="NOVEL" layout="DOCUMENT"> + <meta expanded="no" heading="H1" charCount="93" wordCount="19" + paraCount="2" cursorPos="119"/> + <name status="sc24b8f" import="ia857f0" active="yes">Title Page</name> + </item> \ No newline at end of file diff --git a/docs/source/fileformatspec15.pdf b/docs/source/fileformatspec15.pdf index b93a7bef3cf16928154c499a78906303551155a0..5115b5b1f82eb1e5e398d2c487e3b20bd618463e 100644 GIT binary patch literal 319906 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 Z`~81@`{gfh>@@xT<_~}P`Cq@h`9G8OYT*C? literal 307438 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ၓgrg1>A0Z^se@3Y)Ad3uvkvEmCa}e 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%0D_YOS2)D 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 SUb*Q^&hpiF-#z~6>FR&ZM371V diff --git a/novelwriter/core/projectxml.py b/novelwriter/core/projectxml.py index 42f4e582..416f0934 100644 --- a/novelwriter/core/projectxml.py +++ b/novelwriter/core/projectxml.py @@ -47,6 +47,7 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) FILE_VERSION = "1.5" # The current project file format version +FILE_REVISION = "1" # The current project file format revision HEX_VERSION = 0x0105 NUM_VERSION = { @@ -105,6 +106,8 @@ class ProjectXMLReader: the content item meta entry. It also moves meta data related to the project or the content into their respective section nodes as attributes. The id attribute was also added to the project. + + Rev 1: Drops the titleFormat section of settings. """ def __init__(self, path): @@ -112,6 +115,7 @@ class ProjectXMLReader: self._state = XMLReadState.NO_ACTION self._root = "" self._version = 0x0 + self._revision = 0 self._appVersion = "" self._hexVersion = 0x0 self._timeStamp = "" @@ -136,6 +140,11 @@ class ProjectXMLReader: """Return the project XML version number.""" return self._version + @property + def xmlRevision(self) -> int: + """Return the project XML revision number.""" + return self._revision + @property def appVersion(self) -> str: """Return the version number who wrote the file.""" @@ -196,6 +205,7 @@ class ProjectXMLReader: logger.debug("XML is '%s' version '%s'", self._root, fileVersion) + self._revision = checkInt(xRoot.attrib.get("fileRevision"), 0) self._appVersion = str(xRoot.attrib.get("appVersion", "")) self._hexVersion = hexToInt(xRoot.attrib.get("hexVersion", "")) self._timeStamp = str(xRoot.attrib.get("timeStamp", "")) @@ -490,10 +500,11 @@ class ProjectXMLWriter: logger.debug("Writing project XML") xRoot = ET.Element("novelWriterXML", attrib={ - "appVersion": str(__version__), - "hexVersion": str(__hexversion__), + "appVersion": str(__version__), + "hexVersion": str(__hexversion__), "fileVersion": FILE_VERSION, - "timeStamp": formatTimeStamp(saveTime), + "fileRevision": FILE_REVISION, + "timeStamp": formatTimeStamp(saveTime), }) # Save Project Meta diff --git a/sample/nwProject.nwx b/sample/nwProject.nwx index 6d04fd18..9094397b 100644 --- a/sample/nwProject.nwx +++ b/sample/nwProject.nwx @@ -1,6 +1,6 @@ - - + + Sample Project Sample Project Jane Smith @@ -20,13 +20,6 @@ E D - - %title% - Chapter %chw%: %title% - %title% - Scene %ch%.%sc%: %title% - - New Notes @@ -65,7 +58,7 @@ Chapter One
- + Making a Scene diff --git a/tests/files/nwProject-1.5.nwx b/tests/files/nwProject-1.5.nwx index 8952b63b..1965de73 100644 --- a/tests/files/nwProject-1.5.nwx +++ b/tests/files/nwProject-1.5.nwx @@ -1,5 +1,5 @@ - + Sample Project Sample Project @@ -20,13 +20,6 @@ E D - - %title% - Chapter %chw%: %title% - %title% - Scene %ch%.%sc%: %title% - - New Notes diff --git a/tests/lipsum/nwProject.nwx b/tests/lipsum/nwProject.nwx index 0a4a69e5..d789716c 100644 --- a/tests/lipsum/nwProject.nwx +++ b/tests/lipsum/nwProject.nwx @@ -1,6 +1,6 @@ - - + + Lorem Ipsum Lorem Ipsum lipsum.com @@ -19,13 +19,6 @@ Replace Text 1 Replace Text 2 - - %title% - Chapter %ch%: %title% - %title% - * * * - - New Note @@ -45,7 +38,7 @@ Novel - + Lorem Ipsum @@ -125,4 +118,4 @@ Ancient Europe -
\ No newline at end of file +
diff --git a/tests/reference/coreProject_NewFileFolder_nwProject.nwx b/tests/reference/coreProject_NewFileFolder_nwProject.nwx index c80520fa..6fd70fb2 100644 --- a/tests/reference/coreProject_NewFileFolder_nwProject.nwx +++ b/tests/reference/coreProject_NewFileFolder_nwProject.nwx @@ -1,5 +1,5 @@ - + New Project New Novel @@ -16,13 +16,6 @@ None - - %title% - %title% - %title% - * * * - - New Note diff --git a/tests/reference/coreProject_NewRoot_nwProject.nwx b/tests/reference/coreProject_NewRoot_nwProject.nwx index c94ce28e..057e3742 100644 --- a/tests/reference/coreProject_NewRoot_nwProject.nwx +++ b/tests/reference/coreProject_NewRoot_nwProject.nwx @@ -1,5 +1,5 @@ - + New Project New Novel @@ -16,13 +16,6 @@ None - - %title% - %title% - %title% - * * * - - New Note diff --git a/tests/reference/coreTools_NewCustomA_nwProject.nwx b/tests/reference/coreTools_NewCustomA_nwProject.nwx index 84419675..3abeea1a 100644 --- a/tests/reference/coreTools_NewCustomA_nwProject.nwx +++ b/tests/reference/coreTools_NewCustomA_nwProject.nwx @@ -1,5 +1,5 @@ - + Test Custom Test Novel @@ -16,13 +16,6 @@ None - - %title% - %title% - %title% - * * * - - New Note diff --git a/tests/reference/coreTools_NewCustomB_nwProject.nwx b/tests/reference/coreTools_NewCustomB_nwProject.nwx index 53e23cd8..a433a3b1 100644 --- a/tests/reference/coreTools_NewCustomB_nwProject.nwx +++ b/tests/reference/coreTools_NewCustomB_nwProject.nwx @@ -1,5 +1,5 @@ - + Test Custom Test Novel @@ -16,13 +16,6 @@ None - - %title% - %title% - %title% - * * * - - New Note diff --git a/tests/reference/coreTools_NewMinimal_nwProject.nwx b/tests/reference/coreTools_NewMinimal_nwProject.nwx index 04ad571c..23a377f5 100644 --- a/tests/reference/coreTools_NewMinimal_nwProject.nwx +++ b/tests/reference/coreTools_NewMinimal_nwProject.nwx @@ -1,5 +1,5 @@ - + New Project New Project @@ -16,13 +16,6 @@ None - - %title% - %title% - %title% - * * * - - New Note diff --git a/tests/reference/guiEditor_Main_Final_nwProject.nwx b/tests/reference/guiEditor_Main_Final_nwProject.nwx index c5c3b367..104080e9 100644 --- a/tests/reference/guiEditor_Main_Final_nwProject.nwx +++ b/tests/reference/guiEditor_Main_Final_nwProject.nwx @@ -1,5 +1,5 @@ - + New Project New Novel @@ -16,13 +16,6 @@ None - - %title% - %title% - %title% - * * * - - New Note diff --git a/tests/reference/guiEditor_Main_Initial_nwProject.nwx b/tests/reference/guiEditor_Main_Initial_nwProject.nwx index 4d758791..e266f65f 100644 --- a/tests/reference/guiEditor_Main_Initial_nwProject.nwx +++ b/tests/reference/guiEditor_Main_Initial_nwProject.nwx @@ -1,5 +1,5 @@ - + New Project New Novel @@ -16,13 +16,6 @@ None - - %title% - %title% - %title% - * * * - - New Note diff --git a/tests/reference/projectXML_ReadLegacy10.nwx b/tests/reference/projectXML_ReadLegacy10.nwx index edcab5b4..9d04ef44 100644 --- a/tests/reference/projectXML_ReadLegacy10.nwx +++ b/tests/reference/projectXML_ReadLegacy10.nwx @@ -1,5 +1,5 @@ - + Sample Project Sample Project @@ -20,13 +20,6 @@ E D - - %title% - Chapter %ch%: %title% - %title% - Scene %ch%.%sc%: %title% - - New Notes diff --git a/tests/reference/projectXML_ReadLegacy11.nwx b/tests/reference/projectXML_ReadLegacy11.nwx index fcc987ef..50924c78 100644 --- a/tests/reference/projectXML_ReadLegacy11.nwx +++ b/tests/reference/projectXML_ReadLegacy11.nwx @@ -1,5 +1,5 @@ - + Sample Project Sample Project @@ -20,13 +20,6 @@ E D - - %title% - Chapter %ch%: %title% - %title% - Scene %ch%.%sc%: %title% - - New Notes diff --git a/tests/reference/projectXML_ReadLegacy12.nwx b/tests/reference/projectXML_ReadLegacy12.nwx index b44e9acf..7d36e380 100644 --- a/tests/reference/projectXML_ReadLegacy12.nwx +++ b/tests/reference/projectXML_ReadLegacy12.nwx @@ -1,5 +1,5 @@ - + Sample Project Sample Project @@ -20,13 +20,6 @@ E D - - %title% - Chapter %chw%: %title% - %title% - Scene %ch%.%sc%: %title% - - New Notes diff --git a/tests/reference/projectXML_ReadLegacy13.nwx b/tests/reference/projectXML_ReadLegacy13.nwx index 06fc3145..c75aa97c 100644 --- a/tests/reference/projectXML_ReadLegacy13.nwx +++ b/tests/reference/projectXML_ReadLegacy13.nwx @@ -1,5 +1,5 @@ - + Sample Project Sample Project @@ -20,13 +20,6 @@ E D - - %title% - Chapter %chw%: %title% - %title% - Scene %ch%.%sc%: %title% - - New Notes diff --git a/tests/reference/projectXML_ReadLegacy14.nwx b/tests/reference/projectXML_ReadLegacy14.nwx index e40e3a07..aef603dd 100644 --- a/tests/reference/projectXML_ReadLegacy14.nwx +++ b/tests/reference/projectXML_ReadLegacy14.nwx @@ -1,5 +1,5 @@ - + Sample Project Sample Project @@ -20,13 +20,6 @@ E D - - %title% - Chapter %chw%: %title% - %title% - Scene %ch%.%sc%: %title% - - New Notes From 135ba3f290962512eb5c473c9f775c864b86d1c5 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 3 Jun 2023 19:05:18 +0200 Subject: [PATCH 54/74] Update the project storage class --- novelwriter/constants.py | 1 - novelwriter/core/projectxml.py | 2 +- novelwriter/core/storage.py | 117 ++++++++++++--------------- novelwriter/tools/manuscript.py | 2 +- novelwriter/tools/manussettings.py | 2 +- tests/test_core/test_core_storage.py | 23 +++--- 6 files changed, 66 insertions(+), 81 deletions(-) diff --git a/novelwriter/constants.py b/novelwriter/constants.py index 46e1c1d6..ddcc1004 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -84,7 +84,6 @@ class nwFiles: INDEX_FILE = "tagsIndex.json" OPTS_FILE = "guiOptions.json" RECENT_FILE = "recentProjects.json" - BUILD_CACHE = "prevBuild.json" BUILDS_FILE = "builds.json" # END Class nwFiles diff --git a/novelwriter/core/projectxml.py b/novelwriter/core/projectxml.py index 416f0934..acec2db4 100644 --- a/novelwriter/core/projectxml.py +++ b/novelwriter/core/projectxml.py @@ -40,7 +40,7 @@ from novelwriter.common import ( ) from novelwriter.constants import nwFiles -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from novelwriter.core.status import NWStatus from novelwriter.core.projectdata import NWProjectData diff --git a/novelwriter/core/storage.py b/novelwriter/core/storage.py index a203cf9b..93794b4c 100644 --- a/novelwriter/core/storage.py +++ b/novelwriter/core/storage.py @@ -1,7 +1,6 @@ """ novelWriter – Project Storage Class =================================== -The main class handling the project storage File History: Created: 2022-11-01 [2.0rc2] NWStorage @@ -22,10 +21,12 @@ 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 . """ +from __future__ import annotations import logging from time import time +from typing import TYPE_CHECKING from pathlib import Path from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile @@ -36,29 +37,32 @@ from novelwriter.constants import nwFiles from novelwriter.core.document import NWDocument from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter +if TYPE_CHECKING: # pragma: no cover + from novelwriter.core.project import NWProject + logger = logging.getLogger(__name__) class NWStorage: + """Core: Project Storage Class + + The class that handles all paths related to the project storage. + """ MODE_INACTIVE = 0 MODE_INPLACE = 1 MODE_ARCHIVE = 2 - def __init__(self, theProject): - - self.theProject = theProject - + def __init__(self, project: NWProject): + self._project = project self._storagePath = None self._runtimePath = None self._lockFilePath = None self._openMode = self.MODE_INACTIVE - return def clear(self): - """Reset internal variables. - """ + """Reset internal variables.""" self._storagePath = None self._runtimePath = None self._openMode = self.MODE_INACTIVE @@ -69,15 +73,17 @@ class NWStorage: ## @property - def storagePath(self): + def storagePath(self) -> Path | None: + """Get the path where the project is saved.""" return self._storagePath @property - def runtimePath(self): + def runtimePath(self) -> Path | None: + """Get the path where the project is saved at runtime.""" return self._runtimePath @property - def contentPath(self): + def contentPath(self) -> Path | None: """Return the path used for project content. The folder must already exist, otherwise this property is None. """ @@ -94,12 +100,11 @@ class NWStorage: # Core Methods ## - def isOpen(self): - """Check if the storage location is open. - """ + def isOpen(self) -> bool: + """Check if the storage location is open.""" return self._runtimePath is not None - def openProjectInPlace(self, path, newProject=False): + def openProjectInPlace(self, path: str | Path, newProject: bool = False) -> bool: """Open a novelWriter project in-place. That is, it is opened directly from a project folder. """ @@ -124,13 +129,15 @@ class NWStorage: return True - def openProjectArchive(self, path): # pragma: no cover - """Placeholder for later implementation. See #977. + def openProjectArchive(self, path: str | Path) -> bool: # pragma: no cover + """Open the project from a single file. + Placeholder for later implementation. See #977. """ return False - def runPostSaveTasks(self, autoSave=False): # pragma: no cover + def runPostSaveTasks(self, autoSave: bool = False) -> bool: # pragma: no cover """Run tasks after the project has been saved. + Placeholder for later implementation. See #977. """ if self._openMode == self.MODE_INPLACE: # Nothing to do, so we just return @@ -139,8 +146,7 @@ class NWStorage: return True def closeSession(self): - """Run tasks related to closing the session. - """ + """Run tasks related to closing the session.""" # Clear lockfile self.clear() return @@ -149,51 +155,35 @@ class NWStorage: # Content Access Methods ## - def getXmlReader(self): - """Return a properly configured ProjectXMLReader instance. - """ + def getXmlReader(self) -> ProjectXMLReader | None: + """Return a properly configured ProjectXMLReader instance.""" if self._runtimePath is None: return None - projFile = self._runtimePath / nwFiles.PROJ_FILE xmlReader = ProjectXMLReader(projFile) - return xmlReader - def getXmlWriter(self): - """Return a properly configured ProjectXMLWriter instance. - """ + def getXmlWriter(self) -> ProjectXMLWriter | None: + """Return a properly configured ProjectXMLWriter instance.""" if self._runtimePath is None: return None - xmlWriter = ProjectXMLWriter(self._runtimePath) - return xmlWriter - def getDocument(self, tHandle): - """Return a document wrapper object. - """ + def getDocument(self, tHandle: str) -> NWDocument: + """Return a document wrapper object.""" if self._runtimePath is not None: - return NWDocument(self.theProject, tHandle) - return NWDocument(self.theProject, None) + return NWDocument(self._project, tHandle) + return NWDocument(self._project, None) - def getMetaFile(self, fileName): - """Return the path to a file in the project meta folder. - """ + def getMetaFile(self, fileName: str) -> Path | None: + """Return the path to a file in the project meta folder.""" if self._runtimePath is not None: return self._runtimePath / "meta" / fileName return None - def getCacheFile(self, fileName): - """Return the path to a file in the project cache folder. - """ - if self._runtimePath is not None: - return self._runtimePath / "cache" / fileName - return None - - def readLockFile(self): - """Read the project lock file. - """ + def readLockFile(self) -> list: + """Read the project lock file.""" if self._lockFilePath is None: return ["ERROR"] @@ -212,9 +202,8 @@ class NWStorage: return lines - def writeLockFile(self): - """Write the project lock file. - """ + def writeLockFile(self) -> bool: + """Write the project lock file.""" if self._lockFilePath is None: return False @@ -231,9 +220,8 @@ class NWStorage: return True - def clearLockFile(self): - """Remove the lock file, if it exists. - """ + def clearLockFile(self) -> bool: + """Remove the lock file, if it exists.""" if self._lockFilePath is None: return False @@ -247,7 +235,7 @@ class NWStorage: return True - def zipIt(self, target, compression=None): + def zipIt(self, target: str | Path, compression: int | None = None) -> bool: """Zip the content of the project at its runtime location into a zip file. This process will only grab files that are supposed to be in the project. All non-project files will be left out. @@ -291,9 +279,8 @@ class NWStorage: # Internal Functions ## - def _prepareStorage(self, checkLegacy=True, newProject=False): - """Prepare the storage area for the project. - """ + def _prepareStorage(self, checkLegacy: bool = True, newProject: bool = False) -> bool: + """Prepare the storage area for the project.""" path = self._runtimePath if not isinstance(path, Path): logger.error("No path set") @@ -318,7 +305,6 @@ class NWStorage: try: path.mkdir(exist_ok=True) (path / "content").mkdir(exist_ok=True) - (path / "cache").mkdir(exist_ok=True) (path / "meta").mkdir(exist_ok=True) except Exception as exc: logger.error("Failed to create required project folders", exc_info=exc) @@ -385,8 +371,7 @@ class NWStorage: return def _deleteDeprecatedFiles(self, path: Path): - """Delete files that are no longer used by novelWriter. - """ + """Delete files that are no longer used by novelWriter.""" remove = [ path / "meta" / "mainOptions.json", # Replaced in 0.5 path / "meta" / "exportOptions.json", # Replaced in 0.5 @@ -394,16 +379,20 @@ class NWStorage: path / "meta" / "timelineOptions.json", # Replaced in 0.5 path / "meta" / "docMergeOptions.json", # Replaced in 0.5 path / "meta" / "sessionLogOptions.json", # Replaced in 0.5 + path / "cache" / "prevBuild.json", # Dropped in 2.1 Beta 1 + path / "cache", # Dropped in 2.1 Beta 1 path / "ToC.json", # Dropped in 1.0 RC 1 ] for item in remove: - if item.is_file(): + if item.exists(): try: - item.unlink() + if item.is_dir(): + item.rmdir() + else: + item.unlink() logger.info("Deleted: %s", item) except Exception as exc: logger.warning("Failed to delete: %s", item, exc_info=exc) - return # END Class NWStorage diff --git a/novelwriter/tools/manuscript.py b/novelwriter/tools/manuscript.py index 2260650a..3a7796cb 100644 --- a/novelwriter/tools/manuscript.py +++ b/novelwriter/tools/manuscript.py @@ -46,7 +46,7 @@ from novelwriter.core.docbuild import NWBuildDocument from novelwriter.core.buildsettings import BuildCollection, BuildSettings from novelwriter.tools.manussettings import GuiBuildSettings -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from novelwriter.guimain import GuiMain logger = logging.getLogger(__name__) diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index f97df0c5..43766068 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -47,7 +47,7 @@ from novelwriter.extensions.switchbox import NSwitchBox from novelwriter.extensions.configlayout import NConfigLayout, NSimpleLayout from novelwriter.extensions.pagedsidebar import NPagedSideBar -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from novelwriter.guimain import GuiMain from novelwriter.gui.theme import GuiTheme diff --git a/tests/test_core/test_core_storage.py b/tests/test_core/test_core_storage.py index 5baa8cf0..c0410964 100644 --- a/tests/test_core/test_core_storage.py +++ b/tests/test_core/test_core_storage.py @@ -22,8 +22,8 @@ along with this program. If not, see . from zipfile import ZipFile import pytest -from mocked import causeOSError from tools import C, buildTestProject, writeFile +from mocked import causeOSError from novelwriter import CONFIG from novelwriter.constants import nwFiles @@ -33,13 +33,14 @@ from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter class MockProject: + """Test class for projects.""" + pass @pytest.mark.core def testCoreStorage_OpenProjectInPlace(mockGUI, fncPath, mockRnd): - """Test opening a project in a folder. - """ + """Test opening a project in a folder.""" theProject = NWProject(mockGUI) mockRnd.reset() buildTestProject(theProject, fncPath) @@ -60,7 +61,6 @@ def testCoreStorage_OpenProjectInPlace(mockGUI, fncPath, mockRnd): assert storage.getXmlWriter() is None assert bool(storage.getDocument(C.hSceneDoc)) is False assert storage.getMetaFile("file") is None - assert storage.getCacheFile("file") is None # Open project as a new project should fail assert storage.openProjectInPlace(fncPath, newProject=True) is False @@ -93,7 +93,6 @@ def testCoreStorage_OpenProjectInPlace(mockGUI, fncPath, mockRnd): # Get paths assert storage.getMetaFile("stuff") == fncPath / "meta" / "stuff" - assert storage.getCacheFile("stuff") == fncPath / "cache" / "stuff" # Clean up assert theProject.closeProject() is True @@ -104,15 +103,13 @@ def testCoreStorage_OpenProjectInPlace(mockGUI, fncPath, mockRnd): assert storage.getXmlWriter() is None assert bool(storage.getDocument(C.hSceneDoc)) is False assert storage.getMetaFile("file") is None - assert storage.getCacheFile("file") is None # END Test testCoreStorage_ProjectInPlace @pytest.mark.core def testCoreStorage_LockFile(monkeypatch, fncPath): - """Test the project lock file. - """ + """Test the project lock file.""" monkeypatch.setattr("novelwriter.core.storage.time", lambda: 1000.0) storage = NWStorage(MockProject()) @@ -174,8 +171,7 @@ def testCoreStorage_LockFile(monkeypatch, fncPath): @pytest.mark.core def testCoreStorage_PrepareStorage(monkeypatch, fncPath): - """Test the project path preparation functions. - """ + """Test the project path preparation functions.""" storage = NWStorage(MockProject()) assert storage.isOpen() is False @@ -198,8 +194,8 @@ def testCoreStorage_PrepareStorage(monkeypatch, fncPath): storage._runtimePath = fncPath assert storage._prepareStorage(checkLegacy=False) is True assert (fncPath / "content").exists() - assert (fncPath / "cache").exists() assert (fncPath / "meta").exists() + assert not (fncPath / "cache").exists() # Removed in 2.1b1 # Add a legacy folder storage._runtimePath = fncPath @@ -280,8 +276,10 @@ def testCoreStorage_PrepareStorage(monkeypatch, fncPath): fncPath / "meta" / "timelineOptions.json", fncPath / "meta" / "docMergeOptions.json", fncPath / "meta" / "sessionLogOptions.json", + fncPath / "cache" / "prevBuild.json", fncPath / "ToC.json", ] + (fncPath / "cache").mkdir() for depFile in remove: depFile.write_text("foo") assert depFile.exists() @@ -301,8 +299,7 @@ def testCoreStorage_PrepareStorage(monkeypatch, fncPath): @pytest.mark.core def testCoreStorage_ZipIt(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd): - """Test making a zip archive of a project. - """ + """Test making a zip archive of a project.""" zipFile = tstPaths.tmpDir / "project.zip" theProject = NWProject(mockGUI) From 5f6335d8f9165eec4c8dc8efaab22cbfdaa4941a Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 4 Jun 2023 00:40:04 +0200 Subject: [PATCH 55/74] Fix some typing issues --- novelwriter/common.py | 108 ++++++++++++++---------------- novelwriter/core/buildsettings.py | 8 +-- novelwriter/core/options.py | 46 +++++++------ novelwriter/core/projectdata.py | 15 +++-- novelwriter/core/projectxml.py | 4 +- novelwriter/core/storage.py | 2 +- setup.cfg | 2 +- 7 files changed, 95 insertions(+), 90 deletions(-) diff --git a/novelwriter/common.py b/novelwriter/common.py index 386681e4..76779604 100644 --- a/novelwriter/common.py +++ b/novelwriter/common.py @@ -29,13 +29,13 @@ import hashlib import logging import xml.etree.ElementTree as ET -from typing import Any +from typing import Any, Literal from pathlib import Path from datetime import datetime from configparser import ConfigParser from PyQt5.QtCore import QCoreApplication -from PyQt5.QtWidgets import qApp +from PyQt5.QtWidgets import QWidget, qApp from novelwriter.enum import nwItemClass, nwItemType, nwItemLayout from novelwriter.error import logException @@ -113,14 +113,14 @@ def checkHandle(value, default, allowNone=False): def checkUuid(value: Any, default: str) -> str: - """Try to process a value as an uuid, or return a default.""" + """Try to process a value as an UUID, or return a default.""" try: return str(uuid.UUID(value)) except Exception: return default -def checkPath(value, default): +def checkPath(value: Any, default: Path) -> Path: """Check if a value is a valid path. Non-empty strings are accepted. """ if isinstance(value, Path): @@ -135,7 +135,7 @@ def checkPath(value, default): # Validator Functions # =============================================================================================== # -def isHandle(value): +def isHandle(value: Any) -> bool: """Check if a string is a valid novelWriter handle. Note: This is case sensitive. Must be lower case! """ @@ -149,9 +149,8 @@ def isHandle(value): return True -def isTitleTag(value): - """Check if a string is a valid title tag string. - """ +def isTitleTag(value: Any) -> bool: + """Check if a string is a valid title tag string.""" if not isinstance(value, str): return False if len(value) != 5: @@ -164,27 +163,23 @@ def isTitleTag(value): return True -def isItemClass(value): - """Check if a string is a valid nwItemClass identifier. - """ +def isItemClass(value: str) -> bool: + """Check if a string is a valid nwItemClass identifier.""" return value in nwItemClass.__members__ -def isItemType(value): - """Check if a string is a valid nwItemType identifier. - """ +def isItemType(value: str) -> bool: + """Check if a string is a valid nwItemType identifier.""" return value in nwItemType.__members__ -def isItemLayout(value): - """Check if a string is a valid nwItemLayout identifier. - """ +def isItemLayout(value: str) -> bool: + """Check if a string is a valid nwItemLayout identifier.""" return value in nwItemLayout.__members__ -def hexToInt(value, default=0): - """Convert a hex string to an integer. - """ +def hexToInt(value: Any, default: int = 0) -> int: + """Convert a hex string to an integer.""" if isinstance(value, str): try: return int(value, 16) @@ -193,13 +188,13 @@ def hexToInt(value, default=0): return default -def minmax(value, minVal, maxVal): +def minmax(value: int, minVal: int, maxVal: int) -> int: """Make sure an integer is between min and max value (inclusive). """ return min(maxVal, max(minVal, value)) -def checkIntTuple(value, valid, default): +def checkIntTuple(value: int, valid: tuple | list | set, default: int) -> int: """Check that an int is an element of a tuple. If it isn't, return the default value. """ @@ -213,7 +208,7 @@ def checkIntTuple(value, valid, default): # Formatting Functions # =============================================================================================== # -def formatInt(value): +def formatInt(value: int) -> str: """Formats an integer with k, M, G etc. """ if not isinstance(value, int): @@ -260,20 +255,19 @@ def formatTime(t: int) -> str: # String Functions # =============================================================================================== # -def simplified(string): - """Take a string an strip leading and trailing whitespaces, and +def simplified(text: str) -> str: + """Take a string and strip leading and trailing whitespaces, and replace all occurences of (multiple) whitespaces with a 0x20 space. """ - return " ".join(str(string).strip().split()) + return " ".join(str(text).strip().split()) -def yesNo(value): - """Convert a boolean evaluated variable to a yes or no. - """ +def yesNo(value: int | bool | None) -> Literal["yes", "no"]: + """Convert a boolean evaluated variable to a yes or no.""" return "yes" if value else "no" -def transferCase(source, target): +def transferCase(source: str, target: str) -> str: """Transfers the case of the source word to the target word. This will consider all upper or lower, and first char capitalisation. """ @@ -295,7 +289,7 @@ def transferCase(source, target): return theResult -def fuzzyTime(seconds): +def fuzzyTime(seconds: int) -> str: """Converts a time difference in seconds into a fuzzy time string. """ if seconds < 0: @@ -356,7 +350,7 @@ def fuzzyTime(seconds): ).format(int(round(seconds/31557600))) -def numberToRoman(value, toLower=False): +def numberToRoman(value: int, toLower: bool = False) -> str: """Convert an integer to a Roman number. """ if not isinstance(value, int): @@ -384,7 +378,7 @@ def numberToRoman(value, toLower=False): # Encoder Functions # =============================================================================================== # -def jsonEncode(data, n=0, nmax=0): +def jsonEncode(data: dict | list | tuple, n: int = 0, nmax: int = 0) -> str: """Encode a dictionary, list or tuple as a json object or array, and indent from level n up to a max level nmax if nmax is larger than 0. """ @@ -474,7 +468,7 @@ def xmlIndent(tree: ET.Element | ET.ElementTree): # File and File System Functions # =============================================================================================== # -def readTextFile(path): +def readTextFile(path: str | Path) -> str: """Read the content of a text file in a robust manner. """ path = Path(path) @@ -488,7 +482,7 @@ def readTextFile(path): return "" -def makeFileNameSafe(value): +def makeFileNameSafe(value: str) -> str: """Returns a filename safe string of the value. """ clean = "" @@ -498,7 +492,7 @@ def makeFileNameSafe(value): return clean -def sha256sum(path): +def sha256sum(path: str | Path) -> str | None: """Make a shasum of a file using a buffer. Based on: https://stackoverflow.com/a/44873382/5825851 """ @@ -521,7 +515,7 @@ def sha256sum(path): # Other Functions # =============================================================================================== # -def getGuiItem(objName): +def getGuiItem(objName: str) -> QWidget | None: """Returns a QtWidget based on its objectName. """ for qWidget in qApp.topLevelWidgets(): @@ -535,50 +529,49 @@ def getGuiItem(objName): # =============================================================================================== # class NWConfigParser(ConfigParser): + """Common: Adapted Config Parser + + This is a subclass of the standard config parser that adds type safe + helper functions, and support for lists. + """ def __init__(self): super().__init__() - def rdStr(self, section, option, default): - """Read string value. - """ + def rdStr(self, section: str, option: str, default: str) -> str: + """Read string value.""" return self.get(section, option, fallback=default) - def rdInt(self, section, option, default): - """Read integer value. - """ + def rdInt(self, section: str, option: str, default: int) -> int: + """Read integer value.""" try: return self.getint(section, option, fallback=default) except ValueError: logger.error("Could not read '%s':'%s' from config", section, option) return default - def rdFlt(self, section, option, default): - """Read float value. - """ + def rdFlt(self, section: str, option: str, default: float) -> float: + """Read float value.""" try: return self.getfloat(section, option, fallback=default) except ValueError: logger.error("Could not read '%s':'%s' from config", section, option) return default - def rdBool(self, section, option, default): - """Read boolean value. - """ + def rdBool(self, section: str, option: str, default: bool) -> bool: + """Read boolean value.""" try: return self.getboolean(section, option, fallback=default) except ValueError: logger.error("Could not read '%s':'%s' from config", section, option) return default - def rdPath(self, section, option, default): - """Read a path value. - """ + def rdPath(self, section: str, option: str, default: Path) -> Path: + """Read a Path value.""" return checkPath(self.get(section, option, fallback=default), default) - def rdStrList(self, section, option, default): - """Read string list. - """ + def rdStrList(self, section: str, option: str, default: list[str]) -> list[str]: + """Read string list.""" result = default.copy() if isinstance(default, list) else [] if self.has_option(section, option): data = self.get(section, option, fallback="").split(",") @@ -586,9 +579,8 @@ class NWConfigParser(ConfigParser): result[i] = data[i].strip() return result - def rdIntList(self, section, option, default): - """Read integer list. - """ + def rdIntList(self, section: str, option: str, default: list[int]) -> list[int]: + """Read integer list.""" result = default.copy() if isinstance(default, list) else [] if self.has_option(section, option): data = self.get(section, option, fallback="").split(",") diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index 5a04f00b..34f1e1a3 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -150,17 +150,17 @@ class BuildSettings: @property def name(self) -> str: - """Return the build name.""" + """The build name.""" return self._name @property def buildID(self) -> str: - """Return the build ID.""" + """The build ID as an UUID.""" return self._uuid @property def changed(self) -> bool: - """Return the changed status of the build.""" + """The changed status of the build.""" return self._changed ## @@ -190,7 +190,7 @@ class BuildSettings: return 0 def getFloat(self, key: str) -> float: - """Type safe value access for float.""" + """Type safe value access for floats.""" value = self._settings.get(key, SETTINGS_TEMPLATE.get(key, (None, None)[1])) if isinstance(value, float): return value diff --git a/novelwriter/core/options.py b/novelwriter/core/options.py index bad425b1..cf52a3f2 100644 --- a/novelwriter/core/options.py +++ b/novelwriter/core/options.py @@ -1,7 +1,6 @@ """ novelWriter – Project Options Cache =================================== -Data class for user-defined GUI project options File History: Created: 2019-10-21 [0.3.1] @@ -23,17 +22,22 @@ 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 . """ +from __future__ import annotations import json import logging from enum import Enum +from typing import TYPE_CHECKING, Any from pathlib import Path from novelwriter.error import logException from novelwriter.common import checkBool, checkFloat, checkInt, checkString from novelwriter.constants import nwFiles +if TYPE_CHECKING: # pragma: no cover + from novelwriter.core.project import NWProject + logger = logging.getLogger(__name__) VALID_MAP = { @@ -63,9 +67,15 @@ VALID_MAP = { class OptionState: + """Core: GUI Options Storage - def __init__(self, theProject): - self.theProject = theProject + A class for storing the state of the GUI. The data is stored per + project. Settings that should be project-independent are stored in + the Config instead. + """ + + def __init__(self, project: NWProject): + self._project = project self._theState = {} return @@ -73,10 +83,10 @@ class OptionState: # Load and Save Cache ## - def loadSettings(self): + def loadSettings(self) -> bool: """Load the options dictionary from the project settings file. """ - stateFile = self.theProject.storage.getMetaFile(nwFiles.OPTS_FILE) + stateFile = self._project.storage.getMetaFile(nwFiles.OPTS_FILE) if not isinstance(stateFile, Path): return False @@ -101,10 +111,9 @@ class OptionState: return True - def saveSettings(self): - """Save the options dictionary to the project settings file. - """ - stateFile = self.theProject.storage.getMetaFile(nwFiles.OPTS_FILE) + def saveSettings(self) -> bool: + """Save the options dictionary to the project settings file.""" + stateFile = self._project.storage.getMetaFile(nwFiles.OPTS_FILE) if not isinstance(stateFile, Path): return False @@ -123,9 +132,8 @@ class OptionState: # Setters ## - def setValue(self, group, name, value): - """Save a value, with a given group and name. - """ + def setValue(self, group: str, name: str, value: Any) -> bool: + """Save a value, with a given group and name.""" if group not in VALID_MAP: logger.error("Unknown option group '%s'", group) return False @@ -148,7 +156,7 @@ class OptionState: # Getters ## - def getValue(self, group, name, default): + def getValue(self, group: str, name: str, default: Any) -> Any: """Return an arbitrary type value, if it exists. Otherwise, return the default value. """ @@ -156,7 +164,7 @@ class OptionState: return self._theState[group].get(name, default) return default - def getString(self, group, name, default): + def getString(self, group: str, name: str, default: str) -> str: """Return the value as a string, if it exists. Otherwise, return the default value. """ @@ -164,7 +172,7 @@ class OptionState: return checkString(self._theState[group].get(name, default), default) return default - def getInt(self, group, name, default): + def getInt(self, group: str, name: str, default: int) -> int: """Return the value as an int, if it exists. Otherwise, return the default value. """ @@ -172,7 +180,7 @@ class OptionState: return checkInt(self._theState[group].get(name, default), default) return default - def getFloat(self, group, name, default): + def getFloat(self, group: str, name: str, default: float) -> float: """Return the value as a float, if it exists. Otherwise, return the default value. """ @@ -180,7 +188,7 @@ class OptionState: return checkFloat(self._theState[group].get(name, default), default) return default - def getBool(self, group, name, default): + def getBool(self, group: str, name: str, default: bool) -> bool: """Return the value as a bool, if it exists. Otherwise, return the default value. """ @@ -188,9 +196,9 @@ class OptionState: return checkBool(self._theState[group].get(name, default), default) return default - def getEnum(self, group, name, lookup, default): + def getEnum(self, group: str, name: str, lookup: type, default: Enum) -> Enum: """Return the value mapped to an enum. Otherwise return the - default value + default value. """ if issubclass(lookup, Enum): if group in self._theState: diff --git a/novelwriter/core/projectdata.py b/novelwriter/core/projectdata.py index ea7415b6..2a405798 100644 --- a/novelwriter/core/projectdata.py +++ b/novelwriter/core/projectdata.py @@ -285,15 +285,20 @@ class NWProjectData: self.theProject.setProjectChanged(True) return - def setLastHandle(self, value: dict, component: str | None = None): - """Set a last used handle into the handle registry. If component - is None, the value is assumed to be the whole dictionary of - values. + def setLastHandle(self, value: str | None, component: str): + """Set a last used handle into the handle registry for a given + component. """ if isinstance(component, str): self._lastHandle[component] = checkStringNone(value, None) self.theProject.setProjectChanged(True) - elif isinstance(value, dict): + return + + def setLastHandles(self, value: dict): + """Set the full last handles dictionary to a new set of values. + This is intended to be used at project load. + """ + if isinstance(value, dict): for key, entry in value.items(): if key in self._lastHandle: self._lastHandle[key] = str(entry) if isHandle(entry) else None diff --git a/novelwriter/core/projectxml.py b/novelwriter/core/projectxml.py index acec2db4..628dd390 100644 --- a/novelwriter/core/projectxml.py +++ b/novelwriter/core/projectxml.py @@ -30,8 +30,8 @@ import xml.etree.ElementTree as ET from enum import Enum from time import time -from pathlib import Path from typing import TYPE_CHECKING +from pathlib import Path from novelwriter import __version__, __hexversion__ from novelwriter.common import ( @@ -284,7 +284,7 @@ class ProjectXMLReader: elif xItem.tag == "importance": self._parseStatusImport(xItem, data.itemImport) elif xItem.tag == "lastHandle": - data.setLastHandle(self._parseDictKeyText(xItem)) + data.setLastHandles(self._parseDictKeyText(xItem)) elif xItem.tag == "autoReplace": if self._version >= 0x0102: data.setAutoReplace(self._parseDictKeyText(xItem)) diff --git a/novelwriter/core/storage.py b/novelwriter/core/storage.py index 93794b4c..8574b3df 100644 --- a/novelwriter/core/storage.py +++ b/novelwriter/core/storage.py @@ -170,7 +170,7 @@ class NWStorage: xmlWriter = ProjectXMLWriter(self._runtimePath) return xmlWriter - def getDocument(self, tHandle: str) -> NWDocument: + def getDocument(self, tHandle: str | None) -> NWDocument: """Return a document wrapper object.""" if self._runtimePath is not None: return NWDocument(self._project, tHandle) diff --git a/setup.cfg b/setup.cfg index 53e27d09..b9572076 100644 --- a/setup.cfg +++ b/setup.cfg @@ -50,6 +50,6 @@ gui_scripts = universal = 0 [flake8] -ignore = D107,D205,D400,E133,E221,E226,E228,E241,W503 +ignore = E133,E221,E226,E228,E241,W503 max-line-length = 99 exclude = docs/* From 3996ff8798f19a4c2e2c466fc68a0d1aa8804920 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 4 Jun 2023 20:58:50 +0200 Subject: [PATCH 56/74] Assemble most of the Manuscript Build tool --- novelwriter/__init__.py | 1 - .../assets/icons/typicons_dark/icons.conf | 3 + .../icons/typicons_dark/typ_export-button.svg | 4 + .../icons/typicons_dark/typ_folder-open.svg | 4 + .../typicons_dark/typ_refresh-flipped.svg | 4 + .../assets/icons/typicons_light/icons.conf | 3 + .../typicons_light/typ_export-button.svg | 4 + .../icons/typicons_light/typ_folder-open.svg | 4 + .../typicons_light/typ_refresh-flipped.svg | 4 + novelwriter/common.py | 17 +- novelwriter/constants.py | 10 + novelwriter/core/buildsettings.py | 60 ++++- novelwriter/core/options.py | 3 + novelwriter/extensions/switchbox.py | 36 +-- novelwriter/gui/theme.py | 6 +- novelwriter/tools/manusbuild.py | 240 +++++++++++++++++- novelwriter/tools/manuscript.py | 38 +-- tests/test_base/test_base_common.py | 94 +++---- 18 files changed, 429 insertions(+), 106 deletions(-) create mode 100644 novelwriter/assets/icons/typicons_dark/typ_export-button.svg create mode 100644 novelwriter/assets/icons/typicons_dark/typ_folder-open.svg create mode 100644 novelwriter/assets/icons/typicons_dark/typ_refresh-flipped.svg create mode 100644 novelwriter/assets/icons/typicons_light/typ_export-button.svg create mode 100644 novelwriter/assets/icons/typicons_light/typ_folder-open.svg create mode 100644 novelwriter/assets/icons/typicons_light/typ_refresh-flipped.svg diff --git a/novelwriter/__init__.py b/novelwriter/__init__.py index 85ac0d62..8d1c68f7 100644 --- a/novelwriter/__init__.py +++ b/novelwriter/__init__.py @@ -1,7 +1,6 @@ """ novelWriter – Init File ======================= -Application initialisation File History: Created: 2018-09-22 [0.0.1] diff --git a/novelwriter/assets/icons/typicons_dark/icons.conf b/novelwriter/assets/icons/typicons_dark/icons.conf index e30b48f0..85fa06a7 100644 --- a/novelwriter/assets/icons/typicons_dark/icons.conf +++ b/novelwriter/assets/icons/typicons_dark/icons.conf @@ -19,6 +19,7 @@ licenseurl = https://creativecommons.org/licenses/by-sa/4.0/ add = typ_plus.svg backward = typ_chevron-left.svg bookmark = typ_bookmark.svg +browse = typ_folder-open.svg build_excluded = typ_cancel.svg build_filtered = typ_filter.svg build_included = typ_pin.svg @@ -40,6 +41,7 @@ cls_world = typ_location.svg cross = typ_times.svg down = typ_chevron-down.svg edit = typ_pencil.svg +export = typ_export-button.svg forward = typ_chevron-right.svg maximise = typ_arrow-maximise.svg menu = typ_th-menu.svg @@ -57,6 +59,7 @@ proj_title = mixed_document-title.svg reference = typ_at.svg refresh = typ_refresh.svg remove = typ_minus.svg +revert = typ_refresh-flipped.svg search = typ_search.svg search_cancel = typ_cancel-grey.svg search_case = nw_search-case.svg diff --git a/novelwriter/assets/icons/typicons_dark/typ_export-button.svg b/novelwriter/assets/icons/typicons_dark/typ_export-button.svg new file mode 100644 index 00000000..a82893ff --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/typ_export-button.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/icons/typicons_dark/typ_folder-open.svg b/novelwriter/assets/icons/typicons_dark/typ_folder-open.svg new file mode 100644 index 00000000..072bff87 --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/typ_folder-open.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/icons/typicons_dark/typ_refresh-flipped.svg b/novelwriter/assets/icons/typicons_dark/typ_refresh-flipped.svg new file mode 100644 index 00000000..19f19130 --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/typ_refresh-flipped.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/icons/typicons_light/icons.conf b/novelwriter/assets/icons/typicons_light/icons.conf index fde507ba..b2cacaf1 100644 --- a/novelwriter/assets/icons/typicons_light/icons.conf +++ b/novelwriter/assets/icons/typicons_light/icons.conf @@ -19,6 +19,7 @@ licenseurl = https://creativecommons.org/licenses/by-sa/4.0/ add = typ_plus.svg backward = typ_chevron-left.svg bookmark = typ_bookmark.svg +browse = typ_folder-open.svg build_excluded = typ_cancel.svg build_filtered = typ_filter.svg build_included = typ_pin.svg @@ -40,6 +41,7 @@ cls_world = typ_location.svg cross = typ_times.svg down = typ_chevron-down.svg edit = typ_pencil.svg +export = typ_export-button.svg forward = typ_chevron-right.svg maximise = typ_arrow-maximise.svg menu = typ_th-menu.svg @@ -57,6 +59,7 @@ proj_title = mixed_document-title.svg reference = typ_at.svg refresh = typ_refresh.svg remove = typ_minus.svg +revert = typ_refresh-flipped.svg search = typ_search.svg search_cancel = typ_cancel-grey.svg search_case = nw_search-case.svg diff --git a/novelwriter/assets/icons/typicons_light/typ_export-button.svg b/novelwriter/assets/icons/typicons_light/typ_export-button.svg new file mode 100644 index 00000000..c74e8814 --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/typ_export-button.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/icons/typicons_light/typ_folder-open.svg b/novelwriter/assets/icons/typicons_light/typ_folder-open.svg new file mode 100644 index 00000000..69c4ab24 --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/typ_folder-open.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/icons/typicons_light/typ_refresh-flipped.svg b/novelwriter/assets/icons/typicons_light/typ_refresh-flipped.svg new file mode 100644 index 00000000..0987b6a5 --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/typ_refresh-flipped.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/common.py b/novelwriter/common.py index 76779604..739c9e53 100644 --- a/novelwriter/common.py +++ b/novelwriter/common.py @@ -27,6 +27,7 @@ import json import uuid import hashlib import logging +import unicodedata import xml.etree.ElementTree as ET from typing import Any, Literal @@ -469,8 +470,7 @@ def xmlIndent(tree: ET.Element | ET.ElementTree): # =============================================================================================== # def readTextFile(path: str | Path) -> str: - """Read the content of a text file in a robust manner. - """ + """Read the content of a text file in a robust manner.""" path = Path(path) if not path.is_file(): return "" @@ -482,14 +482,13 @@ def readTextFile(path: str | Path) -> str: return "" -def makeFileNameSafe(value: str) -> str: - """Returns a filename safe string of the value. +def makeFileNameSafe(text: str) -> str: + """Return a filename safe string. + See: https://unicode.org/reports/tr15/#Norm_Forms """ - clean = "" - for c in str(value).strip(): - if c.isalpha() or c.isdigit() or c == " ": - clean += c - return clean + text = unicodedata.normalize("NFKC", text).strip() + allowed = (" ", ".", "-", "_") + return "".join(c for c in text if c.isalnum() or c in allowed) def sha256sum(path: str | Path) -> str | None: diff --git a/novelwriter/constants.py b/novelwriter/constants.py index ddcc1004..ce43284c 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -199,6 +199,16 @@ class nwLabels: nwOutline.CUSTOM: KEY_NAME[nwKeyWords.CUSTOM_KEY], nwOutline.SYNOP: QT_TRANSLATE_NOOP("Constant", "Synopsis"), } + BUILD_FORMATS = { + "odt": ("odt", QT_TRANSLATE_NOOP("Constant", "Open Document (.odt)")), + "fodt": ("fodt", QT_TRANSLATE_NOOP("Constant", "Flat Open Document (.fodt)")), + "html": ("html", QT_TRANSLATE_NOOP("Constant", "novelWriter HTML (.html)")), + "nwd": ("nwd", QT_TRANSLATE_NOOP("Constant", "novelWriter Markdown (.nwd)")), + "md": ("md", QT_TRANSLATE_NOOP("Constant", "Standard Markdown (.md)")), + "md+": ("md", QT_TRANSLATE_NOOP("Constant", "Extended Markdown (.md)")), + "jhtml": ("json", QT_TRANSLATE_NOOP("Constant", "JSON + novelWriter HTML (.json)")), + "jnwd": ("json", QT_TRANSLATE_NOOP("Constant", "JSON + novelWriter Markdown (.json)")), + } # END Class nwLabels diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index 34f1e1a3..cf0701e0 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -35,7 +35,7 @@ from pathlib import Path from PyQt5.QtCore import QT_TRANSLATE_NOOP from novelwriter.common import checkUuid, isHandle, jsonEncode -from novelwriter.constants import nwFiles, nwHeadFmt +from novelwriter.constants import nwFiles, nwHeadFmt, nwLabels from novelwriter.core.item import NWItem from novelwriter.core.project import NWProject from novelwriter.error import logException @@ -72,6 +72,9 @@ SETTINGS_TEMPLATE = { "format.replaceTabs": (bool, False), "odt.addColours": (bool, True), "html.addStyles": (bool, False), + "build.splitNovel": (bool, False), + "build.splitNotes": (bool, False), + "build.splitChapters": (bool, False), } SETTINGS_LABELS = { @@ -112,6 +115,11 @@ SETTINGS_LABELS = { "html": QT_TRANSLATE_NOOP("Builds", "HTML"), "html.addStyles": QT_TRANSLATE_NOOP("Builds", "Add CSS Styles"), + + "build": QT_TRANSLATE_NOOP("Builds", "Build Options"), + "build.splitNovel": QT_TRANSLATE_NOOP("Builds", "Split Novel Root Folders"), + "build.splitNotes": QT_TRANSLATE_NOOP("Builds", "Split Note Root Folders"), + "build.splitChapters": QT_TRANSLATE_NOOP("Builds", "Split By Chapter Document"), } @@ -137,6 +145,9 @@ class BuildSettings: def __init__(self): self._name = "" self._uuid = str(uuid.uuid4()) + self._path = Path.home() + self._build = "" + self._format = "odt" self._skipRoot = set() self._excluded = set() self._included = set() @@ -158,6 +169,23 @@ class BuildSettings: """The build ID as an UUID.""" return self._uuid + @property + def lastPath(self) -> Path: + """The last used build path.""" + if self._path.is_dir(): + return self._path + return Path.home() + + @property + def lastBuildName(self) -> str: + """The last used build name.""" + return self._build + + @property + def lastFormat(self) -> str: + """The last used build format.""" + return self._format + @property def changed(self) -> bool: """The changed status of the build.""" @@ -214,6 +242,30 @@ class BuildSettings: self._uuid = value return + def setLastPath(self, path: Path | str | None): + """Set the last used build path.""" + if isinstance(path, str): + path = Path(path) + if isinstance(path, Path) and path.is_dir(): + self._path = path + else: + self._path = Path.home() + self._changed = True + return + + def setLastBuildName(self, name: str): + """Set the last used build name.""" + self._build = str(name).strip() + self._changed = True + return + + def setLastFormat(self, key: str): + """Set the last used build format.""" + if key in nwLabels.BUILD_FORMATS: + self._format = key + self._changed = True + return + def setFiltered(self, tHandle: str): """Set an item as filtered.""" self._excluded.discard(tHandle) @@ -325,6 +377,9 @@ class BuildSettings: return { "name": self._name, "uuid": self._uuid, + "path": str(self._path), + "build": self._build, + "format": self._format, "settings": self._settings.copy(), "content": { "included": list(self._included), @@ -343,6 +398,9 @@ class BuildSettings: self.setName(data.get("name", "")) self.setBuildID(data.get("uuid", "")) + self.setLastPath(data.get("path", None)) + self.setLastBuildName(data.get("build", "")) + self.setLastFormat(data.get("format", "odt")) if isinstance(included, list): self._included = set([h for h in included if isHandle(h)]) if isinstance(excluded, list): diff --git a/novelwriter/core/options.py b/novelwriter/core/options.py index cf52a3f2..89cb6ab9 100644 --- a/novelwriter/core/options.py +++ b/novelwriter/core/options.py @@ -63,6 +63,9 @@ VALID_MAP = { "GuiManuscript": { "winWidth", "winHeight", "optsWidth", "viewWidth" }, + "GuiManuscriptBuild": { + "winWidth", "winHeight", "fmtWidth", "optsWidth" + }, } diff --git a/novelwriter/extensions/switchbox.py b/novelwriter/extensions/switchbox.py index f1895ca9..03d8befc 100644 --- a/novelwriter/extensions/switchbox.py +++ b/novelwriter/extensions/switchbox.py @@ -1,7 +1,6 @@ """ novelWriter – Custom Widget: Switch Box ======================================= -A box of icons, labels and switches File History: Created: 2023-04-16 [2.1b1] @@ -22,32 +21,35 @@ 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 . """ +from __future__ import annotations from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QGridLayout, QLabel, QScrollArea, QSizePolicy, QWidget from novelwriter.extensions.switch import NSwitch class NSwitchBox(QScrollArea): + """Extension: Switch Box Widget + + A widget that can hold a list of switches with labels and optional + icons. The switch toggles emits a common signal with a switch key. + """ switchToggled = pyqtSignal(str, bool) - def __init__(self, parent, baseSize): + def __init__(self, parent: QWidget, baseSize: int): super().__init__(parent=parent) - self._index = 0 self._hSwitch = baseSize self._wSwitch = 2*self._hSwitch self._sIcon = baseSize - self.clear() - return def clear(self): - """Rebuild the content of the core widget. - """ + """Rebuild the content of the core widget.""" self._content = QGridLayout() self._content.setColumnStretch(1, 1) @@ -60,9 +62,8 @@ class NSwitchBox(QScrollArea): return - def addLabel(self, text): - """Add a header label to the content box. - """ + def addLabel(self, text: str): + """Add a header label to the content box.""" label = QLabel(text) font = label.font() font.setBold(True) @@ -71,9 +72,8 @@ class NSwitchBox(QScrollArea): self._bumpIndex() return - def addItem(self, qIcon, text, identifier, default=False): - """Add an item to the content box. - """ + def addItem(self, qIcon: QIcon, text: str, identifier: str, default: bool = False): + """Add an item to the content box.""" icon = QLabel("") icon.setAlignment(Qt.AlignRight | Qt.AlignVCenter) icon.setPixmap(qIcon.pixmap(self._sIcon, self._sIcon)) @@ -100,13 +100,17 @@ class NSwitchBox(QScrollArea): self._bumpIndex() return + def setInnerContentsMargins(self, left: int, top: int, right: int, bottom: int): + """Set the contents margins of the inner layout.""" + self._content.setContentsMargins(left, top, right, bottom) + return + ## # Internal Functions ## - def _emitSwitchSignal(self, identifier, state): - """Emit a signal for a switch toggle. - """ + def _emitSwitchSignal(self, identifier: str, state: bool): + """Emit a signal for a switch toggle.""" self.switchToggled.emit(identifier, state) return diff --git a/novelwriter/gui/theme.py b/novelwriter/gui/theme.py index f1c3f0a4..52bbfb78 100644 --- a/novelwriter/gui/theme.py +++ b/novelwriter/gui/theme.py @@ -454,9 +454,9 @@ class GuiIcons: "view_novel", "view_outline", # General Button Icons - "add", "backward", "bookmark", "checked", "close", "cross", "down", "edit", "forward", - "maximise", "menu", "minimise", "noncheckable", "reference", "refresh", "remove", - "search_replace", "search", "settings", "unchecked", "up", + "add", "backward", "bookmark", "browse", "checked", "close", "cross", "down", "edit", + "export", "forward", "maximise", "menu", "minimise", "noncheckable", "reference", + "refresh", "remove", "revert", "search_replace", "search", "settings", "unchecked", "up", # Switches "sticky-on", "sticky-off", diff --git a/novelwriter/tools/manusbuild.py b/novelwriter/tools/manusbuild.py index 199dbf7f..439976b9 100644 --- a/novelwriter/tools/manusbuild.py +++ b/novelwriter/tools/manusbuild.py @@ -25,7 +25,23 @@ from __future__ import annotations import logging -from PyQt5.QtWidgets import QDialog, QWidget +from typing import TYPE_CHECKING + +from PyQt5.QtCore import Qt, pyqtSlot +from PyQt5.QtWidgets import ( + QAbstractButton, QDialog, QDialogButtonBox, QFrame, QGridLayout, + QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QProgressBar, + QPushButton, QSplitter, QVBoxLayout, QWidget +) + +from novelwriter import CONFIG +from novelwriter.common import makeFileNameSafe +from novelwriter.constants import nwLabels +from novelwriter.core.buildsettings import BuildSettings +from novelwriter.extensions.switchbox import NSwitchBox + +if TYPE_CHECKING: + from novelwriter.guimain import GuiMain logger = logging.getLogger(__name__) @@ -37,13 +53,146 @@ class GuiManuscriptBuild(QDialog): independently of the Manuscript Build Tool. """ - def __init__(self, parent: QWidget): + def __init__(self, parent: QWidget, mainGui: GuiMain, build: BuildSettings): super().__init__(parent=parent) logger.debug("Create: GuiManuscriptBuild") self.setObjectName("GuiManuscriptBuild") + self.mainGui = mainGui + self.mainTheme = mainGui.mainTheme + self.theProject = mainGui.theProject + self._parent = parent + self._build = build + + self.setWindowTitle(self.tr("Build Manuscript")) + self.setMinimumWidth(CONFIG.pxInt(500)) + self.setMinimumHeight(CONFIG.pxInt(250)) + + wWin = CONFIG.pxInt(660) + hWin = CONFIG.pxInt(350) + + pOptions = self.theProject.options + self.resize( + CONFIG.pxInt(pOptions.getInt("GuiManuscriptBuild", "winWidth", wWin)), + CONFIG.pxInt(pOptions.getInt("GuiManuscriptBuild", "winHeight", hWin)) + ) + + # Formats + # ======= + + self.lblFormat = QLabel("{0}".format(self.tr("Build Format"))) + self.listFormats = QListWidget() + current = None + for key, (_, label) in nwLabels.BUILD_FORMATS.items(): + item = QListWidgetItem() + item.setText(label) + item.setData(Qt.UserRole, key) + self.listFormats.addItem(item) + if key == self._build.lastFormat: + current = item + if current: + self.listFormats.setCurrentItem(current) + + self.formatBox = QVBoxLayout() + self.formatBox.addWidget(self.lblFormat, 0) + self.formatBox.addWidget(self.listFormats, 1) + self.formatBox.setContentsMargins(0, 0, 0, 0) + + self.formatWidget = QWidget() + self.formatWidget.setLayout(self.formatBox) + self.formatWidget.setContentsMargins(0, 0, 0, 0) + + # Build Controls + # ============== + + # Build Options + self.swtOptions = NSwitchBox(self, self.mainTheme.baseIconSize) + self.swtOptions.switchToggled.connect(self._applyBuildOptions) + self.swtOptions.setFrameStyle(QFrame.NoFrame) + self.swtOptions.setInnerContentsMargins(0, 0, 0, 0) + + self.swtOptions.addLabel(self._build.getLabel("build")) + self.swtOptions.addItem( + self.mainTheme.getIcon("cls_novel"), self._build.getLabel("build.splitNovel"), + "build.splitNovel", default=self._build.getBool("build.splitNovel") + ) + self.swtOptions.addItem( + self.mainTheme.getIcon("cls_custom"), self._build.getLabel("build.splitNotes"), + "build.splitNotes", default=self._build.getBool("build.splitNotes") + ) + self.swtOptions.addItem( + self.mainTheme.getIcon("proj_chapter"), self._build.getLabel("build.splitChapters"), + "build.splitChapters", default=self._build.getBool("build.splitChapters") + ) + + # Dialog Controls + # =============== + + # Build Path + self.lblPath = QLabel(self.tr("Build Folder")) + self.buildPath = QLineEdit() + self.buildPath.setText(str(self._build.lastPath)) + self.btnBrowse = QPushButton(self.mainTheme.getIcon("browse"), "") + + self.pathBox = QHBoxLayout() + self.pathBox.addWidget(self.buildPath) + self.pathBox.addWidget(self.btnBrowse) + + # Build Name + self.lblName = QLabel(self.tr("Build Name")) + self.buildName = QLineEdit() + self.btnReset = QPushButton(self.mainTheme.getIcon("revert"), "") + self.btnReset.setToolTip(self.tr("Reset Build Name to default")) + self.btnReset.clicked.connect(self._doResetBuildName) + + self.nameBox = QHBoxLayout() + self.nameBox.addWidget(self.buildName) + self.nameBox.addWidget(self.btnReset) + + # Build Progress + self.lblProgress = QLabel(self.tr("Build Progress")) + self.buildProgress = QProgressBar() + + # Dialog Buttons + self.dlgButtons = QDialogButtonBox(QDialogButtonBox.Close) + self.dlgButtons.addButton( + QPushButton(self.mainTheme.getIcon("export"), self.tr("&Build")), + QDialogButtonBox.ActionRole + ) + self.dlgButtons.clicked.connect(self._dialogButtonClicked) + + # Assemble GUI + # ============ + + self.mainSplit = QSplitter() + self.mainSplit.addWidget(self.formatWidget) + self.mainSplit.addWidget(self.swtOptions) + self.mainSplit.setHandleWidth(CONFIG.pxInt(16)) + self.mainSplit.setCollapsible(0, False) + self.mainSplit.setCollapsible(1, False) + self.mainSplit.setSizes([ + CONFIG.pxInt(pOptions.getInt("GuiManuscriptBuild", "fmtWidth", int(0.45*wWin))), + CONFIG.pxInt(pOptions.getInt("GuiManuscriptBuild", "optsWidth", int(0.55*wWin))), + ]) + + self.outerBox = QGridLayout() + self.outerBox.addWidget(self.mainSplit, 0, 0, 1, 2) + self.outerBox.addWidget(self.lblPath, 1, 0, 1, 1) + self.outerBox.addLayout(self.pathBox, 1, 1, 1, 1) + self.outerBox.addWidget(self.lblName, 2, 0, 1, 1) + self.outerBox.addLayout(self.nameBox, 2, 1, 1, 1) + self.outerBox.addWidget(self.lblProgress, 3, 0, 1, 1) + self.outerBox.addWidget(self.buildProgress, 3, 1, 1, 1) + self.outerBox.addWidget(self.dlgButtons, 4, 0, 1, 2) + + self.setLayout(self.outerBox) + + if self._build.lastBuildName: + self.buildName.setText(makeFileNameSafe(self._build.lastBuildName)) + else: + self._doResetBuildName() logger.debug("Ready: GuiManuscriptBuild") @@ -54,4 +203,91 @@ class GuiManuscriptBuild(QDialog): logger.debug("Delete: GuiManuscriptBuild") return + ## + # Events + ## + + def closeEvent(self, event): + """Capture the user closing the window so we can save GUI + settings. + """ + self._saveSettings() + event.accept() + self.deleteLater() + return + + ## + # Private Slots + ## + + @pyqtSlot(str, bool) + def _applyBuildOptions(self, key: str, state: bool): + """Set the build options for the build.""" + self._build.setValue(key, state) + return + + @pyqtSlot("QAbstractButton*") + def _dialogButtonClicked(self, button: QAbstractButton): + """Handle button clicks from the dialog button box.""" + role = self.dlgButtons.buttonRole(button) + if role == QDialogButtonBox.ActionRole: + self._runBuild() + elif role == QDialogButtonBox.RejectRole: + self.close() + return + + @pyqtSlot() + def _doResetBuildName(self): + """Generate a default build name.""" + bName = makeFileNameSafe(f"{self.theProject.data.name} - {self._build.name}") + self.buildName.setText(bName) + self._build.setLastBuildName(bName) + return + + ## + # Internal Functions + ## + + def _runBuild(self) -> bool: + """Run the currently selected build.""" + bFormat = self._getSelectedFormat() + if not bFormat: + return False + + bPath = self.buildPath.text() + bName = self.buildName.text() + + self._build.setLastFormat(bFormat) + self._build.setLastPath(bPath) + self._build.setLastBuildName(bName) + + return True + + def _getSelectedFormat(self) -> str | None: + """Get the currently selected format.""" + items = self.listFormats.selectedItems() + if items and isinstance(items[0], QListWidgetItem): + return str(items[0].data(Qt.UserRole)) + return None + + def _saveSettings(self): + """Save the user GUI settings.""" + logger.debug("Saving GuiManuscriptBuild settings") + + winWidth = CONFIG.rpxInt(self.width()) + winHeight = CONFIG.rpxInt(self.height()) + + mainSplit = self.mainSplit.sizes() + fmtWidth = CONFIG.rpxInt(mainSplit[0]) + optsWidth = CONFIG.rpxInt(mainSplit[1]) + + pOptions = self.theProject.options + pOptions.setValue("GuiManuscriptBuild", "winWidth", winWidth) + pOptions.setValue("GuiManuscriptBuild", "winHeight", winHeight) + pOptions.setValue("GuiManuscriptBuild", "fmtWidth", fmtWidth) + pOptions.setValue("GuiManuscriptBuild", "optsWidth", optsWidth) + pOptions.saveSettings() + + return + # END Class GuiManuscriptBuild diff --git a/novelwriter/tools/manuscript.py b/novelwriter/tools/manuscript.py index 3a7796cb..05e705ae 100644 --- a/novelwriter/tools/manuscript.py +++ b/novelwriter/tools/manuscript.py @@ -44,6 +44,7 @@ from novelwriter.common import checkInt, fuzzyTime from novelwriter.core.tohtml import ToHtml from novelwriter.core.docbuild import NWBuildDocument from novelwriter.core.buildsettings import BuildCollection, BuildSettings +from novelwriter.tools.manusbuild import GuiManuscriptBuild from novelwriter.tools.manussettings import GuiBuildSettings if TYPE_CHECKING: # pragma: no cover @@ -107,26 +108,16 @@ class GuiManuscript(QDialog): self.btnPreview.clicked.connect(self._generatePreview) self.manPreview = _PreviewWidget(self.mainGui) + self.btnBuild = QPushButton(self.tr("Build")) + self.btnBuild.clicked.connect(self._buildManuscript) + self.menuPrint = QMenu(self) self.aPrintSend = self.menuPrint.addAction(self.tr("Print Preview")) self.aPrintFile = self.menuPrint.addAction(self.tr("Print to PDF")) - self.menuSave = QMenu(self) - self.aSaveODT = self.menuSave.addAction(self.tr("Open Document (.odt)")) - self.aSaveFODT = self.menuSave.addAction(self.tr("Flat Open Document (.fodt)")) - self.aSaveHTM = self.menuSave.addAction(self.tr("novelWriter HTML (.htm)")) - self.aSaveNWD = self.menuSave.addAction(self.tr("novelWriter Markdown (.nwd)")) - self.aSaveMD = self.menuSave.addAction(self.tr("Standard Markdown (.md)")) - self.aSaveGH = self.menuSave.addAction(self.tr("GitHub Markdown (.md)")) - self.aSaveJsonH = self.menuSave.addAction(self.tr("JSON + novelWriter HTML (.json)")) - self.aSaveJsonM = self.menuSave.addAction(self.tr("JSON + novelWriter Markdown (.json)")) - self.btnPrint = QPushButton(self.tr("Print")) self.btnPrint.setMenu(self.menuPrint) - self.btnSave = QPushButton(self.tr("Save As")) - self.btnSave.setMenu(self.menuSave) - self.btnClose = QPushButton(self.tr("Close")) self.btnClose.clicked.connect(self._doClose) @@ -139,7 +130,7 @@ class GuiManuscript(QDialog): self.buildBox.addWidget(self.btnDelete) self.processBox = QHBoxLayout() - self.processBox.addWidget(self.btnSave) + self.processBox.addWidget(self.btnBuild) self.processBox.addWidget(self.btnPrint) self.processBox.addWidget(self.btnClose) @@ -283,6 +274,23 @@ class GuiManuscript(QDialog): return + @pyqtSlot() + def _buildManuscript(self): + """Open the build dialog and build the manuscript.""" + build = self._getSelectedBuild() + if build is None: + return + + dlgBuild = GuiManuscriptBuild(self, self.mainGui, build) + dlgBuild.exec_() + + # After the build is done, save build settings changes + if build.changed: + self._builds.setBuild(build) + self._builds.saveCollection() + + return + @pyqtSlot() def _doClose(self): """Forward the close button to the default close method.""" @@ -514,7 +522,7 @@ class _PreviewWidget(QTextBrowser): if self._docTime > 0: strBuildTime = "%s (%s)" % ( datetime.fromtimestamp(self._docTime).strftime("%x %X"), - fuzzyTime(time() - self._docTime) + fuzzyTime(int(time()) - self._docTime) ) else: strBuildTime = self.tr("Unknown") diff --git a/tests/test_base/test_base_common.py b/tests/test_base/test_base_common.py index c244cd05..7b0f868c 100644 --- a/tests/test_base/test_base_common.py +++ b/tests/test_base/test_base_common.py @@ -25,24 +25,23 @@ import hashlib from pathlib import Path -from mocked import causeOSError from tools import writeFile +from mocked import causeOSError from novelwriter.guimain import GuiMain from novelwriter.common import ( - checkStringNone, checkString, checkInt, checkFloat, checkBool, checkHandle, - checkUuid, checkPath, isHandle, isTitleTag, isItemClass, isItemType, - isItemLayout, hexToInt, minmax, checkIntTuple, formatInt, formatTimeStamp, - formatTime, simplified, yesNo, transferCase, fuzzyTime, numberToRoman, - jsonEncode, readTextFile, makeFileNameSafe, sha256sum, getGuiItem, - NWConfigParser + checkBool, checkFloat, checkHandle, checkInt, checkIntTuple, checkPath, + checkString, checkStringNone, checkUuid, formatInt, formatTime, + formatTimeStamp, fuzzyTime, getGuiItem, hexToInt, isHandle, isItemClass, + isItemLayout, isItemType, isTitleTag, jsonEncode, makeFileNameSafe, minmax, + numberToRoman, NWConfigParser, readTextFile, sha256sum, simplified, + transferCase, yesNo ) @pytest.mark.base def testBaseCommon_CheckStringNone(): - """Test the checkStringNone function. - """ + """Test the checkStringNone function.""" assert checkStringNone("Stuff", "NotNone") == "Stuff" assert checkStringNone("None", "NotNone") is None assert checkStringNone(None, "NotNone") is None @@ -151,8 +150,7 @@ def testBaseCommon_CheckBool(): @pytest.mark.base def testBaseCommon_CheckHandle(): - """Test the checkHandle function. - """ + """Test the checkHandle function.""" assert checkHandle("None", 1, True) is None assert checkHandle("None", 1, False) == 1 assert checkHandle(None, 1, True) is None @@ -165,8 +163,7 @@ def testBaseCommon_CheckHandle(): @pytest.mark.base def testBaseCommon_CheckUuid(): - """Test the checkUuid function. - """ + """Test the checkUuid function.""" testUuid = "e2be99af-f9bf-4403-857a-c3d1ac25abea" assert checkUuid("", None) is None assert checkUuid("e2be99af-f9bf-4403-857a-c3d1ac25abe", None) is None @@ -179,8 +176,7 @@ def testBaseCommon_CheckUuid(): @pytest.mark.base def testBaseCommon_CheckPath(): - """Test the checkPath function. - """ + """Test the checkPath function.""" assert checkPath(Path("test"), None) == Path("test") assert checkPath("test", None) == Path("test") assert checkPath(None, None) is None @@ -192,8 +188,7 @@ def testBaseCommon_CheckPath(): @pytest.mark.base def testBaseCommon_IsHandle(): - """Test the isHandle function. - """ + """Test the isHandle function.""" assert isHandle("47666c91c7ccf") is True assert isHandle("47666C91C7CCF") is False assert isHandle("h7666c91c7ccf") is False @@ -206,8 +201,7 @@ def testBaseCommon_IsHandle(): @pytest.mark.base def testBaseCommon_IsTitleTag(): - """Test the isItemClass function. - """ + """Test the isItemClass function.""" assert isTitleTag("T1234") is True assert isTitleTag("t1234") is False @@ -224,8 +218,7 @@ def testBaseCommon_IsTitleTag(): @pytest.mark.base def testBaseCommon_IsItemClass(): - """Test the isItemClass function. - """ + """Test the isItemClass function.""" assert isItemClass("NO_CLASS") is True assert isItemClass("NOVEL") is True assert isItemClass("PLOT") is True @@ -248,8 +241,7 @@ def testBaseCommon_IsItemClass(): @pytest.mark.base def testBaseCommon_IsItemType(): - """Test the isItemType function. - """ + """Test the isItemType function.""" assert isItemType("NO_TYPE") is True assert isItemType("ROOT") is True assert isItemType("FOLDER") is True @@ -268,8 +260,7 @@ def testBaseCommon_IsItemType(): @pytest.mark.base def testBaseCommon_IsItemLayout(): - """Test the isItemLayout function. - """ + """Test the isItemLayout function.""" assert isItemLayout("NO_LAYOUT") is True assert isItemLayout("DOCUMENT") is True assert isItemLayout("NOTE") is True @@ -293,8 +284,7 @@ def testBaseCommon_IsItemLayout(): @pytest.mark.base def testBaseCommon_HexToInt(): - """Test the hexToInt function. - """ + """Test the hexToInt function.""" assert hexToInt(1) == 0 assert hexToInt("1") == 1 assert hexToInt("0xff") == 255 @@ -307,8 +297,7 @@ def testBaseCommon_HexToInt(): @pytest.mark.base def testBaseCommon_MinMax(): - """Test the minmax function. - """ + """Test the minmax function.""" for i in range(-5, 15): assert 0 <= minmax(i, 0, 10) <= 10 @@ -317,8 +306,7 @@ def testBaseCommon_MinMax(): @pytest.mark.base def testBaseCommon_CheckIntTuple(): - """Test the checkIntTuple function. - """ + """Test the checkIntTuple function.""" assert checkIntTuple(0, (0, 1, 2), 3) == 0 assert checkIntTuple(5, (0, 1, 2), 3) == 3 @@ -327,8 +315,7 @@ def testBaseCommon_CheckIntTuple(): @pytest.mark.base def testBaseCommon_FormatTimeStamp(): - """Test the formatTimeStamp function. - """ + """Test the formatTimeStamp function.""" tTime = time.mktime(time.gmtime(0)) assert formatTimeStamp(tTime, False) == "1970-01-01 00:00:00" assert formatTimeStamp(tTime, True) == "1970-01-01 00.00.00" @@ -338,8 +325,7 @@ def testBaseCommon_FormatTimeStamp(): @pytest.mark.base def testBaseCommon_FormatTime(): - """Test the formatTime function. - """ + """Test the formatTime function.""" assert formatTime("1") == "ERROR" assert formatTime(1.0) == "ERROR" assert formatTime(1) == "00:00:01" @@ -361,8 +347,7 @@ def testBaseCommon_FormatTime(): @pytest.mark.base def testBaseCommon_Simplified(): - """Test the simplified function. - """ + """Test the simplified function.""" assert simplified("Hello World") == "Hello World" assert simplified(" Hello World ") == "Hello World" assert simplified("\tHello\n\r\tWorld") == "Hello World" @@ -372,8 +357,7 @@ def testBaseCommon_Simplified(): @pytest.mark.base def testBaseCommon_YesNo(): - """Test the yesNo function. - """ + """Test the yesNo function.""" # Bool assert yesNo(True) == "yes" assert yesNo(False) == "no" @@ -400,8 +384,7 @@ def testBaseCommon_YesNo(): @pytest.mark.base def testBaseCommon_FormatInt(): - """Test the formatInt function. - """ + """Test the formatInt function.""" # Normal Cases assert formatInt(1) == "1" assert formatInt(12) == "12" @@ -424,8 +407,7 @@ def testBaseCommon_FormatInt(): @pytest.mark.base def testBaseCommon_TransferCase(): - """Test the transferCase function. - """ + """Test the transferCase function.""" assert transferCase(1, "TaRgEt") == "TaRgEt" assert transferCase("source", 1) == 1 assert transferCase("", "TaRgEt") == "TaRgEt" @@ -439,8 +421,7 @@ def testBaseCommon_TransferCase(): @pytest.mark.base def testBaseCommon_FuzzyTime(): - """Test the fuzzyTime function. - """ + """Test the fuzzyTime function.""" assert fuzzyTime(-1) == "in the future" assert fuzzyTime(0) == "just now" assert fuzzyTime(29) == "just now" @@ -475,8 +456,7 @@ def testBaseCommon_FuzzyTime(): @pytest.mark.core def testBaseCommon_RomanNumbers(): - """Test conversion of integers to Roman numbers. - """ + """Test conversion of integers to Roman numbers.""" assert numberToRoman(None, False) == "NAN" assert numberToRoman(0, False) == "OOR" assert numberToRoman(1, False) == "I" @@ -503,8 +483,7 @@ def testBaseCommon_RomanNumbers(): @pytest.mark.base def testBaseCommon_JsonEncode(): - """Test the jsonEncode function. - """ + """Test the jsonEncode function.""" # Wrong type assert jsonEncode(None) == "[]" @@ -587,8 +566,7 @@ def testBaseCommon_JsonEncode(): @pytest.mark.base def testBaseCommon_ReadTextFile(monkeypatch, fncPath, ipsumText): - """Test the readTextFile function. - """ + """Test the readTextFile function.""" testText = "\n\n".join(ipsumText) + "\n" testFile = fncPath / "ipsum.txt" writeFile(testFile, testText) @@ -605,20 +583,20 @@ def testBaseCommon_ReadTextFile(monkeypatch, fncPath, ipsumText): @pytest.mark.base def testBaseCommon_MakeFileNameSafe(): - """Test the makeFileNameSafe function. - """ + """Test the makeFileNameSafe function.""" assert makeFileNameSafe(" aaaa ") == "aaaa" assert makeFileNameSafe("aaaa,bbbb") == "aaaabbbb" assert makeFileNameSafe("aaaa\tbbbb") == "aaaabbbb" assert makeFileNameSafe("aaaa bbbb") == "aaaa bbbb" + assert makeFileNameSafe("æøå") == "æøå" + assert makeFileNameSafe("Stuff œfi2⁵") == "Stuff œfi25" # END Test testBaseCommon_MakeFileNameSafe @pytest.mark.base def testBaseCommon_Sha256Sum(monkeypatch, fncPath, ipsumText): - """Test the sha256sum function. - """ + """Test the sha256sum function.""" longText = 50*(" ".join(ipsumText) + " ") shortText = "This is a short file" noneText = "" @@ -657,8 +635,7 @@ def testBaseCommon_Sha256Sum(monkeypatch, fncPath, ipsumText): @pytest.mark.base def testBaseCommon_GetGuiItem(nwGUI): - """Check the GUI item function. - """ + """Check the GUI item function.""" assert getGuiItem("gibberish") is None assert isinstance(getGuiItem("GuiMain"), GuiMain) @@ -667,8 +644,7 @@ def testBaseCommon_GetGuiItem(nwGUI): @pytest.mark.base def testBaseCommon_NWConfigParser(fncPath): - """Test the NWConfigParser subclass. - """ + """Test the NWConfigParser subclass.""" tstConf = fncPath / "test.cfg" writeFile(tstConf, ( "[main]\n" From 033b9f2a59c5f0810fb6153cad8182301bc7bf79 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 4 Jun 2023 21:25:03 +0200 Subject: [PATCH 57/74] Update the zipIt function to include new files --- novelwriter/constants.py | 14 ++++++++++---- novelwriter/core/storage.py | 12 +++++++----- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/novelwriter/constants.py b/novelwriter/constants.py index ce43284c..e15b6733 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -75,16 +75,22 @@ class nwHeaders: class nwFiles: + # Config Files CONF_FILE = "novelwriter.conf" + RECENT_FILE = "recentProjects.json" + + # Project Root Files PROJ_FILE = "nwProject.nwx" - PROJ_DICT = "wordlist.txt" + PROJ_BACKUP = "nwProject.bak" PROJ_LOCK = "nwProject.lock" TOC_TXT = "ToC.txt" - SESS_STATS = "sessionStats.log" + + # Project Meta Files + BUILDS_FILE = "builds.json" INDEX_FILE = "tagsIndex.json" OPTS_FILE = "guiOptions.json" - RECENT_FILE = "recentProjects.json" - BUILDS_FILE = "builds.json" + PROJ_DICT = "wordlist.txt" + SESS_STATS = "sessionStats.log" # END Class nwFiles diff --git a/novelwriter/core/storage.py b/novelwriter/core/storage.py index 8574b3df..5fed4fce 100644 --- a/novelwriter/core/storage.py +++ b/novelwriter/core/storage.py @@ -248,11 +248,13 @@ class NWStorage: baseMeta = basePath / "meta" baseCont = basePath / "content" files = [ - (basePath / nwFiles.PROJ_FILE, nwFiles.PROJ_FILE), - (baseMeta / nwFiles.OPTS_FILE, f"meta/{nwFiles.OPTS_FILE}"), - (baseMeta / nwFiles.SESS_STATS, f"meta/{nwFiles.SESS_STATS}"), - (baseMeta / nwFiles.INDEX_FILE, f"meta/{nwFiles.INDEX_FILE}"), - (baseMeta / nwFiles.PROJ_DICT, f"meta/{nwFiles.PROJ_DICT}"), + (basePath / nwFiles.PROJ_FILE, nwFiles.PROJ_FILE), + (basePath / nwFiles.PROJ_BACKUP, nwFiles.PROJ_BACKUP), + (baseMeta / nwFiles.BUILDS_FILE, f"meta/{nwFiles.BUILDS_FILE}"), + (baseMeta / nwFiles.INDEX_FILE, f"meta/{nwFiles.INDEX_FILE}"), + (baseMeta / nwFiles.OPTS_FILE, f"meta/{nwFiles.OPTS_FILE}"), + (baseMeta / nwFiles.PROJ_DICT, f"meta/{nwFiles.PROJ_DICT}"), + (baseMeta / nwFiles.SESS_STATS, f"meta/{nwFiles.SESS_STATS}"), ] for contItem in baseCont.iterdir(): name = contItem.name From 2f0ac4fe170acc3695b8f757a3dc43f6dd0a7dbb Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Mon, 5 Jun 2023 00:08:11 +0200 Subject: [PATCH 58/74] Fix Qt.UserRole access warning --- novelwriter/dialogs/docmerge.py | 6 +++-- novelwriter/dialogs/docsplit.py | 6 ++--- novelwriter/dialogs/projload.py | 14 +++++++----- novelwriter/dialogs/projsettings.py | 6 ++--- novelwriter/dialogs/quotes.py | 6 +++-- novelwriter/gui/noveltree.py | 30 +++++++++++++------------ novelwriter/gui/outline.py | 4 ++-- novelwriter/gui/projtree.py | 4 ++-- novelwriter/tools/manuscript.py | 6 +++-- novelwriter/tools/manussettings.py | 4 ++-- tests/test_dialogs/test_dlg_docmerge.py | 4 ++-- tests/test_dialogs/test_dlg_projload.py | 2 +- 12 files changed, 51 insertions(+), 41 deletions(-) diff --git a/novelwriter/dialogs/docmerge.py b/novelwriter/dialogs/docmerge.py index ea04d184..71111f0a 100644 --- a/novelwriter/dialogs/docmerge.py +++ b/novelwriter/dialogs/docmerge.py @@ -41,6 +41,8 @@ logger = logging.getLogger(__name__) class GuiDocMerge(QDialog): + D_HANDLE = Qt.ItemDataRole.UserRole + def __init__(self, mainGui, sHandle, itemList): super().__init__(parent=mainGui) @@ -122,7 +124,7 @@ class GuiDocMerge(QDialog): for i in range(self.listBox.count()): item = self.listBox.item(i) if item is not None and item.checkState() == Qt.Checked: - finalItems.append(item.data(Qt.UserRole)) + finalItems.append(item.data(self.D_HANDLE)) self._data["moveToTrash"] = self.trashSwitch.isChecked() self._data["finalItems"] = finalItems @@ -166,7 +168,7 @@ class GuiDocMerge(QDialog): newItem = QListWidgetItem() newItem.setIcon(itemIcon) newItem.setText(nwItem.itemName) - newItem.setData(Qt.UserRole, tHandle) + newItem.setData(self.D_HANDLE, tHandle) newItem.setCheckState(Qt.Checked) self.listBox.addItem(newItem) diff --git a/novelwriter/dialogs/docsplit.py b/novelwriter/dialogs/docsplit.py index d66e0278..c08c017b 100644 --- a/novelwriter/dialogs/docsplit.py +++ b/novelwriter/dialogs/docsplit.py @@ -41,9 +41,9 @@ logger = logging.getLogger(__name__) class GuiDocSplit(QDialog): - LINE_ROLE = Qt.UserRole - LEVEL_ROLE = Qt.UserRole + 1 - LABEL_ROLE = Qt.UserRole + 2 + LINE_ROLE = Qt.ItemDataRole.UserRole + LEVEL_ROLE = Qt.ItemDataRole.UserRole + 1 + LABEL_ROLE = Qt.ItemDataRole.UserRole + 2 def __init__(self, mainGui, sHandle): super().__init__(parent=mainGui) diff --git a/novelwriter/dialogs/projload.py b/novelwriter/dialogs/projload.py index f00f1c47..8fa3ec7a 100644 --- a/novelwriter/dialogs/projload.py +++ b/novelwriter/dialogs/projload.py @@ -53,6 +53,8 @@ class GuiProjectLoad(QDialog): C_COUNT = 1 C_TIME = 2 + D_PATH = Qt.ItemDataRole.UserRole + def __init__(self, mainGui): super().__init__(parent=mainGui) @@ -167,7 +169,7 @@ class GuiProjectLoad(QDialog): selItems = self.listBox.selectedItems() if selItems: - self.openPath = selItems[0].data(self.C_NAME, Qt.UserRole) + self.openPath = selItems[0].data(self.C_NAME, self.D_PATH) self.openState = self.OPEN_STATE self.accept() @@ -178,7 +180,7 @@ class GuiProjectLoad(QDialog): """ selList = self.listBox.selectedItems() if selList: - self.selPath.setText(selList[0].data(self.C_NAME, Qt.UserRole)) + self.selPath.setText(selList[0].data(self.C_NAME, self.D_PATH)) return def _doBrowse(self): @@ -232,7 +234,7 @@ class GuiProjectLoad(QDialog): ) if msgYes: CONFIG.recentProjects.remove( - selList[0].data(self.C_NAME, Qt.UserRole) + selList[0].data(self.C_NAME, self.D_PATH) ) self._populateList() @@ -274,12 +276,12 @@ class GuiProjectLoad(QDialog): newItem = QTreeWidgetItem([""]*4) newItem.setIcon(self.C_NAME, nwxIcon) newItem.setText(self.C_NAME, title) - newItem.setData(self.C_NAME, Qt.UserRole, path) + newItem.setData(self.C_NAME, self.D_PATH, path) newItem.setText(self.C_COUNT, formatInt(words)) newItem.setText(self.C_TIME, datetime.fromtimestamp(time).strftime("%x %X")) - newItem.setTextAlignment(self.C_NAME, Qt.AlignLeft | Qt.AlignVCenter) + newItem.setTextAlignment(self.C_NAME, Qt.AlignLeft | Qt.AlignVCenter) newItem.setTextAlignment(self.C_COUNT, Qt.AlignRight | Qt.AlignVCenter) - newItem.setTextAlignment(self.C_TIME, Qt.AlignRight | Qt.AlignVCenter) + newItem.setTextAlignment(self.C_TIME, Qt.AlignRight | Qt.AlignVCenter) newItem.setFont(self.C_TIME, self.mainTheme.guiFontFixed) self.listBox.addTopLevelItem(newItem) diff --git a/novelwriter/dialogs/projsettings.py b/novelwriter/dialogs/projsettings.py index f78efd8c..aac956cc 100644 --- a/novelwriter/dialogs/projsettings.py +++ b/novelwriter/dialogs/projsettings.py @@ -277,9 +277,9 @@ class GuiProjectEditStatus(QWidget): COL_LABEL = 0 COL_USAGE = 1 - KEY_ROLE = Qt.UserRole - COL_ROLE = Qt.UserRole + 1 - NUM_ROLE = Qt.UserRole + 2 + KEY_ROLE = Qt.ItemDataRole.UserRole + COL_ROLE = Qt.ItemDataRole.UserRole + 1 + NUM_ROLE = Qt.ItemDataRole.UserRole + 2 def __init__(self, projGui, isStatus): super().__init__(parent=projGui) diff --git a/novelwriter/dialogs/quotes.py b/novelwriter/dialogs/quotes.py index 256888ce..4f5ba9eb 100644 --- a/novelwriter/dialogs/quotes.py +++ b/novelwriter/dialogs/quotes.py @@ -42,6 +42,8 @@ class GuiQuoteSelect(QDialog): selectedQuote = "" + D_KEY = Qt.ItemDataRole.UserRole + def __init__(self, parent=None, currentQuote='"'): super().__init__(parent=parent) @@ -75,7 +77,7 @@ class GuiQuoteSelect(QDialog): theText = "[ %s ] %s" % (sKey, trConst(sLabel)) minSize = max(minSize, qMetrics.boundingRect(theText).width()) qtItem = QListWidgetItem(theText) - qtItem.setData(Qt.UserRole, sKey) + qtItem.setData(self.D_KEY, sKey) self.listBox.addItem(qtItem) if sKey == currentQuote: self.listBox.setCurrentItem(qtItem) @@ -111,7 +113,7 @@ class GuiQuoteSelect(QDialog): """ selItems = self.listBox.selectedItems() if selItems: - theSymbol = selItems[0].data(Qt.UserRole) + theSymbol = selItems[0].data(self.D_KEY) self.previewLabel.setText(theSymbol) self.selectedQuote = theSymbol return diff --git a/novelwriter/gui/noveltree.py b/novelwriter/gui/noveltree.py index aaf9485d..41cee1a9 100644 --- a/novelwriter/gui/noveltree.py +++ b/novelwriter/gui/noveltree.py @@ -383,14 +383,16 @@ class GuiNovelToolBar(QWidget): class GuiNovelTree(QTreeWidget): + C_DATA = 0 C_TITLE = 0 C_WORDS = 1 C_EXTRA = 2 C_MORE = 3 - D_HANDLE = Qt.UserRole - D_TITLE = Qt.UserRole + 1 - D_KEY = Qt.UserRole + 2 + D_HANDLE = Qt.ItemDataRole.UserRole + D_TITLE = Qt.ItemDataRole.UserRole + 1 + D_KEY = Qt.ItemDataRole.UserRole + 2 + D_EXTRA = Qt.ItemDataRole.UserRole + 3 def __init__(self, novelView): super().__init__(parent=novelView) @@ -527,7 +529,7 @@ class GuiNovelTree(QTreeWidget): selItem = self.selectedItems() titleKey = None if selItem: - titleKey = selItem[0].data(self.C_TITLE, self.D_KEY) + titleKey = selItem[0].data(self.C_DATA, self.D_KEY) self._populateTree(rootHandle) self.theProject.data.setLastHandle(rootHandle, "novelTree") @@ -564,8 +566,8 @@ class GuiNovelTree(QTreeWidget): selList = self.selectedItems() trItem = selList[0] if selList else self.currentItem() if isinstance(trItem, QTreeWidgetItem): - tHandle = trItem.data(self.C_TITLE, self.D_HANDLE) - sTitle = trItem.data(self.C_TITLE, self.D_TITLE) + tHandle = trItem.data(self.C_DATA, self.D_HANDLE) + sTitle = trItem.data(self.C_DATA, self.D_TITLE) return tHandle, sTitle return None, None @@ -596,7 +598,7 @@ class GuiNovelTree(QTreeWidget): for i in range(self.topLevelItemCount()): tItem = self.topLevelItem(i) if tItem is not None: - if tItem.data(self.C_TITLE, self.D_HANDLE) == tHandle: + if tItem.data(self.C_DATA, self.D_HANDLE) == tHandle: tItem.setBackground(self.C_TITLE, self.palette().alternateBase()) tItem.setBackground(self.C_WORDS, self.palette().alternateBase()) tItem.setBackground(self.C_EXTRA, self.palette().alternateBase()) @@ -659,7 +661,7 @@ class GuiNovelTree(QTreeWidget): for i in range(self.topLevelItemCount()): trItem = self.topLevelItem(i) if isinstance(trItem, QTreeWidgetItem): - lastText = trItem.data(self.C_EXTRA, Qt.UserRole) + lastText = trItem.data(self.C_DATA, self.D_EXTRA) trItem.setText(self.C_EXTRA, fMetric.elidedText(lastText, Qt.ElideRight, eliW)) return @@ -672,8 +674,8 @@ class GuiNovelTree(QTreeWidget): """The user clicked on an item in the tree. """ if mIndex.column() == self.C_MORE: - tHandle = mIndex.siblingAtColumn(self.C_TITLE).data(self.D_HANDLE) - sTitle = mIndex.siblingAtColumn(self.C_TITLE).data(self.D_TITLE) + tHandle = mIndex.siblingAtColumn(self.C_DATA).data(self.D_HANDLE) + sTitle = mIndex.siblingAtColumn(self.C_DATA).data(self.D_TITLE) tipPos = self.mapToGlobal(self.visualRect(mIndex).topRight()) self._popMetaBox(tipPos, tHandle, sTitle) return @@ -715,9 +717,9 @@ class GuiNovelTree(QTreeWidget): continue newItem = QTreeWidgetItem() - newItem.setData(self.C_TITLE, self.D_HANDLE, tHandle) - newItem.setData(self.C_TITLE, self.D_TITLE, sTitle) - newItem.setData(self.C_TITLE, self.D_KEY, tKey) + newItem.setData(self.C_DATA, self.D_HANDLE, tHandle) + newItem.setData(self.C_DATA, self.D_TITLE, sTitle) + newItem.setData(self.C_DATA, self.D_KEY, tKey) newItem.setTextAlignment(self.C_WORDS, Qt.AlignRight) self._updateTreeItemValues(newItem, novIdx, tHandle, sTitle) @@ -748,7 +750,7 @@ class GuiNovelTree(QTreeWidget): lastText, toolTip = self._getLastColumnText(tHandle, sTitle) elideText = self.fontMetrics().elidedText(lastText, Qt.ElideRight, mW) trItem.setText(self.C_EXTRA, elideText) - trItem.setData(self.C_EXTRA, Qt.UserRole, lastText) + trItem.setData(self.C_DATA, self.D_EXTRA, lastText) trItem.setToolTip(self.C_EXTRA, toolTip) return diff --git a/novelwriter/gui/outline.py b/novelwriter/gui/outline.py index 7e282ab7..03c6c5bf 100644 --- a/novelwriter/gui/outline.py +++ b/novelwriter/gui/outline.py @@ -360,8 +360,8 @@ class GuiOutlineTree(QTreeWidget): nwOutline.SYNOP: False, } - D_HANDLE = Qt.UserRole - D_TITLE = Qt.UserRole + 1 + D_HANDLE = Qt.ItemDataRole.UserRole + D_TITLE = Qt.ItemDataRole.UserRole + 1 hiddenStateChanged = pyqtSignal() activeItemChanged = pyqtSignal(str, str) diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index acc75191..fa31b1b1 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -449,8 +449,8 @@ class GuiProjectTree(QTreeWidget): C_ACTIVE = 2 C_STATUS = 3 - D_HANDLE = Qt.UserRole - D_WORDS = Qt.UserRole + 1 + D_HANDLE = Qt.ItemDataRole.UserRole + D_WORDS = Qt.ItemDataRole.UserRole + 1 def __init__(self, projView): super().__init__(parent=projView) diff --git a/novelwriter/tools/manuscript.py b/novelwriter/tools/manuscript.py index 05e705ae..2e770e2f 100644 --- a/novelwriter/tools/manuscript.py +++ b/novelwriter/tools/manuscript.py @@ -61,6 +61,8 @@ class GuiManuscript(QDialog): a document directly to disk. """ + D_KEY = Qt.ItemDataRole.UserRole + def __init__(self, mainGui: GuiMain): super().__init__(parent=mainGui) @@ -318,7 +320,7 @@ class GuiManuscript(QDialog): """Get the currently selected build.""" bItems = self.buildList.selectedItems() if bItems: - build = self._builds.getBuild(bItems[0].data(Qt.UserRole)) + build = self._builds.getBuild(bItems[0].data(self.D_KEY)) if isinstance(build, BuildSettings): return build return None @@ -364,7 +366,7 @@ class GuiManuscript(QDialog): for key, name in self._builds.builds(): bItem = QListWidgetItem() bItem.setText(name) - bItem.setData(Qt.UserRole, key) + bItem.setData(self.D_KEY, key) self.buildList.addItem(bItem) self._buildMap[key] = bItem return diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index 43766068..a831f650 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -285,8 +285,8 @@ class _FilterTab(QWidget): C_ACTIVE = 1 C_STATUS = 2 - D_HANDLE = Qt.UserRole - D_FILE = Qt.UserRole + 1 + D_HANDLE = Qt.ItemDataRole.UserRole + D_FILE = Qt.ItemDataRole.UserRole + 1 F_NONE = 0 F_FILTERED = 1 diff --git a/tests/test_dialogs/test_dlg_docmerge.py b/tests/test_dialogs/test_dlg_docmerge.py index c2f876d2..8071b555 100644 --- a/tests/test_dialogs/test_dlg_docmerge.py +++ b/tests/test_dialogs/test_dlg_docmerge.py @@ -52,8 +52,8 @@ def testDlgMerge_Main(qtbot, nwGUI, projPath, mockRnd): itemOne = nwMerge.listBox.item(0) itemTwo = nwMerge.listBox.item(1) - assert itemOne.data(Qt.UserRole) == C.hChapterDoc - assert itemTwo.data(Qt.UserRole) == C.hSceneDoc + assert itemOne.data(Qt.ItemDataRole.UserRole) == C.hChapterDoc + assert itemTwo.data(Qt.ItemDataRole.UserRole) == C.hSceneDoc assert itemOne.checkState() == Qt.Checked assert itemTwo.checkState() == Qt.Checked diff --git a/tests/test_dialogs/test_dlg_projload.py b/tests/test_dialogs/test_dlg_projload.py index 6f226fda..e5afe6aa 100644 --- a/tests/test_dialogs/test_dlg_projload.py +++ b/tests/test_dialogs/test_dlg_projload.py @@ -51,7 +51,7 @@ def testDlgLoadProject_Main(qtbot, monkeypatch, nwGUI, projPath): assert recentCount > 0 selItem = nwLoad.listBox.topLevelItem(0) - selPath = selItem.data(nwLoad.C_NAME, Qt.UserRole) + selPath = selItem.data(nwLoad.C_NAME, Qt.ItemDataRole.UserRole) assert isinstance(selItem, QTreeWidgetItem) nwLoad.selPath.setText("") From ee9da2bdf1fca8333e562eb2a8faeee0e76425b8 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Mon, 5 Jun 2023 00:25:02 +0200 Subject: [PATCH 59/74] Improve the manuscript GUIs a bit --- .../icons/typicons_dark/typ_export-button.svg | 2 +- .../typicons_light/typ_export-button.svg | 2 +- novelwriter/tools/manusbuild.py | 46 ++++++++----- novelwriter/tools/manuscript.py | 67 ++++++++++++++----- 4 files changed, 83 insertions(+), 34 deletions(-) diff --git a/novelwriter/assets/icons/typicons_dark/typ_export-button.svg b/novelwriter/assets/icons/typicons_dark/typ_export-button.svg index a82893ff..06452fcb 100644 --- a/novelwriter/assets/icons/typicons_dark/typ_export-button.svg +++ b/novelwriter/assets/icons/typicons_dark/typ_export-button.svg @@ -1,4 +1,4 @@ - + diff --git a/novelwriter/assets/icons/typicons_light/typ_export-button.svg b/novelwriter/assets/icons/typicons_light/typ_export-button.svg index c74e8814..7d43e7a1 100644 --- a/novelwriter/assets/icons/typicons_light/typ_export-button.svg +++ b/novelwriter/assets/icons/typicons_light/typ_export-button.svg @@ -1,4 +1,4 @@ - + diff --git a/novelwriter/tools/manusbuild.py b/novelwriter/tools/manusbuild.py index 439976b9..77d04a71 100644 --- a/novelwriter/tools/manusbuild.py +++ b/novelwriter/tools/manusbuild.py @@ -53,6 +53,8 @@ class GuiManuscriptBuild(QDialog): independently of the Manuscript Build Tool. """ + D_KEY = Qt.ItemDataRole.UserRole + def __init__(self, parent: QWidget, mainGui: GuiMain, build: BuildSettings): super().__init__(parent=parent) @@ -71,7 +73,7 @@ class GuiManuscriptBuild(QDialog): self.setMinimumHeight(CONFIG.pxInt(250)) wWin = CONFIG.pxInt(660) - hWin = CONFIG.pxInt(350) + hWin = CONFIG.pxInt(390) pOptions = self.theProject.options self.resize( @@ -88,7 +90,7 @@ class GuiManuscriptBuild(QDialog): for key, (_, label) in nwLabels.BUILD_FORMATS.items(): item = QListWidgetItem() item.setText(label) - item.setData(Qt.UserRole, key) + item.setData(self.D_KEY, key) self.listFormats.addItem(item) if key == self._build.lastFormat: current = item @@ -130,6 +132,18 @@ class GuiManuscriptBuild(QDialog): # Dialog Controls # =============== + font = self.font() + font.setBold(True) + font.setUnderline(True) + font.setPointSizeF(1.5*font.pointSizeF()) + + self.lblMain = QLabel(self._build.name) + self.lblMain.setWordWrap(True) + self.lblMain.setFont(font) + + self.spaceWidget = QWidget() + self.spaceWidget.setFixedHeight(CONFIG.pxInt(8)) + # Build Path self.lblPath = QLabel(self.tr("Build Folder")) self.buildPath = QLineEdit() @@ -156,11 +170,9 @@ class GuiManuscriptBuild(QDialog): self.buildProgress = QProgressBar() # Dialog Buttons + self.btnBuild = QPushButton(self.mainTheme.getIcon("export"), self.tr("&Build")) self.dlgButtons = QDialogButtonBox(QDialogButtonBox.Close) - self.dlgButtons.addButton( - QPushButton(self.mainTheme.getIcon("export"), self.tr("&Build")), - QDialogButtonBox.ActionRole - ) + self.dlgButtons.addButton(self.btnBuild, QDialogButtonBox.ActionRole) self.dlgButtons.clicked.connect(self._dialogButtonClicked) # Assemble GUI @@ -178,14 +190,16 @@ class GuiManuscriptBuild(QDialog): ]) self.outerBox = QGridLayout() - self.outerBox.addWidget(self.mainSplit, 0, 0, 1, 2) - self.outerBox.addWidget(self.lblPath, 1, 0, 1, 1) - self.outerBox.addLayout(self.pathBox, 1, 1, 1, 1) - self.outerBox.addWidget(self.lblName, 2, 0, 1, 1) - self.outerBox.addLayout(self.nameBox, 2, 1, 1, 1) - self.outerBox.addWidget(self.lblProgress, 3, 0, 1, 1) - self.outerBox.addWidget(self.buildProgress, 3, 1, 1, 1) - self.outerBox.addWidget(self.dlgButtons, 4, 0, 1, 2) + self.outerBox.addWidget(self.lblMain, 0, 0, 1, 2, Qt.AlignCenter) + self.outerBox.addWidget(self.spaceWidget, 1, 0, 1, 2) + self.outerBox.addWidget(self.mainSplit, 2, 0, 1, 2) + self.outerBox.addWidget(self.lblPath, 3, 0, 1, 1) + self.outerBox.addLayout(self.pathBox, 3, 1, 1, 1) + self.outerBox.addWidget(self.lblName, 4, 0, 1, 1) + self.outerBox.addLayout(self.nameBox, 4, 1, 1, 1) + self.outerBox.addWidget(self.lblProgress, 5, 0, 1, 1) + self.outerBox.addWidget(self.buildProgress, 5, 1, 1, 1) + self.outerBox.addWidget(self.dlgButtons, 6, 0, 1, 2) self.setLayout(self.outerBox) @@ -194,6 +208,8 @@ class GuiManuscriptBuild(QDialog): else: self._doResetBuildName() + self.btnBuild.setFocus() + logger.debug("Ready: GuiManuscriptBuild") return @@ -267,7 +283,7 @@ class GuiManuscriptBuild(QDialog): """Get the currently selected format.""" items = self.listFormats.selectedItems() if items and isinstance(items[0], QListWidgetItem): - return str(items[0].data(Qt.UserRole)) + return str(items[0].data(self.D_KEY)) return None def _saveSettings(self): diff --git a/novelwriter/tools/manuscript.py b/novelwriter/tools/manuscript.py index 2e770e2f..fbdbac8e 100644 --- a/novelwriter/tools/manuscript.py +++ b/novelwriter/tools/manuscript.py @@ -31,10 +31,10 @@ from typing import TYPE_CHECKING from datetime import datetime from PyQt5.QtGui import QColor, QCursor, QFont, QPalette, QResizeEvent -from PyQt5.QtCore import QTimer, Qt, pyqtSlot +from PyQt5.QtCore import QSize, QTimer, Qt, pyqtSlot from PyQt5.QtWidgets import ( QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QMenu, - QProgressBar, QPushButton, QSplitter, QTextBrowser, QVBoxLayout, QWidget, + QProgressBar, QPushButton, QSplitter, QTextBrowser, QToolButton, QVBoxLayout, QWidget, qApp ) @@ -80,6 +80,7 @@ class GuiManuscript(QDialog): self.setMinimumWidth(CONFIG.pxInt(600)) self.setMinimumHeight(CONFIG.pxInt(500)) + iPx = self.mainTheme.baseIconSize wWin = CONFIG.pxInt(900) hWin = CONFIG.pxInt(600) @@ -92,15 +93,52 @@ class GuiManuscript(QDialog): # Build Controls # ============== + qPalette = self.palette() + qPalette.setBrush(QPalette.Window, qPalette.base()) + self.setPalette(qPalette) + + fadeCol = qPalette.text().color() + buttonStyle = ( + "QToolButton {{padding: {0}px; border: none; background: transparent;}} " + "QToolButton:hover {{border: none; background: rgba({1},{2},{3},0.2);}}" + ).format(CONFIG.pxInt(2), fadeCol.red(), fadeCol.green(), fadeCol.blue()) + + self.tbAdd = QToolButton(self) + self.tbAdd.setIcon(self.mainTheme.getIcon("add")) + self.tbAdd.setIconSize(QSize(iPx, iPx)) + self.tbAdd.setToolTip(self.tr("Add New Build")) + self.tbAdd.setStyleSheet(buttonStyle) + self.tbAdd.clicked.connect(self._createNewBuild) + + self.tbDel = QToolButton(self) + self.tbDel.setIcon(self.mainTheme.getIcon("remove")) + self.tbDel.setIconSize(QSize(iPx, iPx)) + self.tbDel.setToolTip(self.tr("Remove Selected Build")) + self.tbDel.setStyleSheet(buttonStyle) + + self.tbEdit = QToolButton(self) + self.tbEdit.setIcon(self.mainTheme.getIcon("edit")) + self.tbEdit.setIconSize(QSize(iPx, iPx)) + self.tbEdit.setToolTip(self.tr("Edit Selected Build")) + self.tbEdit.setStyleSheet(buttonStyle) + self.tbEdit.clicked.connect(self._editSelectedBuild) + + self.lblBuilds = QLabel("{0}".format(self.tr("Builds"))) self.buildList = QListWidget() - self.btnNew = QPushButton(self.tr("New")) - self.btnNew.clicked.connect(self._createNewBuild) + self.tbMore = QToolButton(self) + self.tbMore.setIcon(self.mainTheme.getIcon("menu")) + self.tbMore.setIconSize(QSize(iPx, iPx)) + self.tbMore.setStyleSheet(buttonStyle) - self.btnEdit = QPushButton(self.tr("Edit")) - self.btnEdit.clicked.connect(self._editSelectedBuild) - - self.btnDelete = QPushButton(self.tr("Delete")) + self.listToolBox = QHBoxLayout() + self.listToolBox.addWidget(self.lblBuilds) + self.listToolBox.addStretch(1) + self.listToolBox.addWidget(self.tbAdd) + self.listToolBox.addWidget(self.tbDel) + self.listToolBox.addWidget(self.tbEdit) + self.listToolBox.addWidget(self.tbMore) + self.listToolBox.setSpacing(0) # Process Controls # ================ @@ -123,22 +161,17 @@ class GuiManuscript(QDialog): self.btnClose = QPushButton(self.tr("Close")) self.btnClose.clicked.connect(self._doClose) - # Assemble GUI - # ============ - - self.buildBox = QHBoxLayout() - self.buildBox.addWidget(self.btnNew) - self.buildBox.addWidget(self.btnEdit) - self.buildBox.addWidget(self.btnDelete) - self.processBox = QHBoxLayout() self.processBox.addWidget(self.btnBuild) self.processBox.addWidget(self.btnPrint) self.processBox.addWidget(self.btnClose) + # Assemble GUI + # ============ + self.controlBox = QVBoxLayout() + self.controlBox.addLayout(self.listToolBox) self.controlBox.addWidget(self.buildList) - self.controlBox.addLayout(self.buildBox) self.controlBox.addWidget(self.buildProgress) self.controlBox.addWidget(self.btnPreview) self.controlBox.addLayout(self.processBox) From 18df1193320868407d3be6b2105e4a2466567a40 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Mon, 5 Jun 2023 17:19:32 +0200 Subject: [PATCH 60/74] Update build tools icons --- .../assets/icons/typicons_dark/icons.conf | 4 ++-- .../icons/typicons_dark/typ_export-button.svg | 4 ---- .../icons/typicons_dark/typ_export-grey.svg | 4 ++++ .../assets/icons/typicons_dark/typ_export.svg | 2 +- .../assets/icons/typicons_dark/typ_filter.svg | 18 +++--------------- .../assets/icons/typicons_light/icons.conf | 4 ++-- .../icons/typicons_light/typ_export-button.svg | 4 ---- .../icons/typicons_light/typ_export-grey.svg | 4 ++++ .../assets/icons/typicons_light/typ_export.svg | 2 +- .../assets/icons/typicons_light/typ_filter.svg | 18 +++--------------- 10 files changed, 20 insertions(+), 44 deletions(-) delete mode 100644 novelwriter/assets/icons/typicons_dark/typ_export-button.svg create mode 100644 novelwriter/assets/icons/typicons_dark/typ_export-grey.svg delete mode 100644 novelwriter/assets/icons/typicons_light/typ_export-button.svg create mode 100644 novelwriter/assets/icons/typicons_light/typ_export-grey.svg diff --git a/novelwriter/assets/icons/typicons_dark/icons.conf b/novelwriter/assets/icons/typicons_dark/icons.conf index 85fa06a7..2e62c460 100644 --- a/novelwriter/assets/icons/typicons_dark/icons.conf +++ b/novelwriter/assets/icons/typicons_dark/icons.conf @@ -41,7 +41,7 @@ cls_world = typ_location.svg cross = typ_times.svg down = typ_chevron-down.svg edit = typ_pencil.svg -export = typ_export-button.svg +export = typ_export.svg forward = typ_chevron-right.svg maximise = typ_arrow-maximise.svg menu = typ_th-menu.svg @@ -79,7 +79,7 @@ sticky-off = typ_pin-outline.svg sticky-on = typ_pin.svg unchecked = mixed_input-unchecked.svg up = typ_chevron-up.svg -view_build = typ_export.svg +view_build = typ_export-grey.svg view_editor = mixed_edit.svg view_novel = typ_book-grey.svg view_outline = typ_puzzle-outline.svg diff --git a/novelwriter/assets/icons/typicons_dark/typ_export-button.svg b/novelwriter/assets/icons/typicons_dark/typ_export-button.svg deleted file mode 100644 index 06452fcb..00000000 --- a/novelwriter/assets/icons/typicons_dark/typ_export-button.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/novelwriter/assets/icons/typicons_dark/typ_export-grey.svg b/novelwriter/assets/icons/typicons_dark/typ_export-grey.svg new file mode 100644 index 00000000..aa6de8d4 --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/typ_export-grey.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/icons/typicons_dark/typ_export.svg b/novelwriter/assets/icons/typicons_dark/typ_export.svg index aa6de8d4..06452fcb 100644 --- a/novelwriter/assets/icons/typicons_dark/typ_export.svg +++ b/novelwriter/assets/icons/typicons_dark/typ_export.svg @@ -1,4 +1,4 @@ - + diff --git a/novelwriter/assets/icons/typicons_dark/typ_filter.svg b/novelwriter/assets/icons/typicons_dark/typ_filter.svg index 01e9c667..fd3f3afc 100644 --- a/novelwriter/assets/icons/typicons_dark/typ_filter.svg +++ b/novelwriter/assets/icons/typicons_dark/typ_filter.svg @@ -1,16 +1,4 @@ - - - - + + + diff --git a/novelwriter/assets/icons/typicons_light/icons.conf b/novelwriter/assets/icons/typicons_light/icons.conf index b2cacaf1..df1d40ee 100644 --- a/novelwriter/assets/icons/typicons_light/icons.conf +++ b/novelwriter/assets/icons/typicons_light/icons.conf @@ -41,7 +41,7 @@ cls_world = typ_location.svg cross = typ_times.svg down = typ_chevron-down.svg edit = typ_pencil.svg -export = typ_export-button.svg +export = typ_export.svg forward = typ_chevron-right.svg maximise = typ_arrow-maximise.svg menu = typ_th-menu.svg @@ -79,7 +79,7 @@ sticky-off = typ_pin-outline.svg sticky-on = typ_pin.svg unchecked = mixed_input-unchecked.svg up = typ_chevron-up.svg -view_build = typ_export.svg +view_build = typ_export-grey.svg view_editor = mixed_edit.svg view_novel = typ_book-grey.svg view_outline = typ_puzzle-outline.svg diff --git a/novelwriter/assets/icons/typicons_light/typ_export-button.svg b/novelwriter/assets/icons/typicons_light/typ_export-button.svg deleted file mode 100644 index 7d43e7a1..00000000 --- a/novelwriter/assets/icons/typicons_light/typ_export-button.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/novelwriter/assets/icons/typicons_light/typ_export-grey.svg b/novelwriter/assets/icons/typicons_light/typ_export-grey.svg new file mode 100644 index 00000000..eed8ef4b --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/typ_export-grey.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/icons/typicons_light/typ_export.svg b/novelwriter/assets/icons/typicons_light/typ_export.svg index eed8ef4b..7d43e7a1 100644 --- a/novelwriter/assets/icons/typicons_light/typ_export.svg +++ b/novelwriter/assets/icons/typicons_light/typ_export.svg @@ -1,4 +1,4 @@ - + diff --git a/novelwriter/assets/icons/typicons_light/typ_filter.svg b/novelwriter/assets/icons/typicons_light/typ_filter.svg index e7f86fba..40000608 100644 --- a/novelwriter/assets/icons/typicons_light/typ_filter.svg +++ b/novelwriter/assets/icons/typicons_light/typ_filter.svg @@ -1,16 +1,4 @@ - - - - + + + From b0201c58e935cec4a309547ec575f677c77b1eea Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Mon, 5 Jun 2023 21:49:30 +0200 Subject: [PATCH 61/74] Add Table of Contents to build dialog --- novelwriter/core/buildsettings.py | 8 --- novelwriter/core/options.py | 2 +- novelwriter/tools/manusbuild.py | 95 +++++++++++++++++++----------- novelwriter/tools/manuscript.py | 15 +++-- novelwriter/tools/manussettings.py | 16 +++-- 5 files changed, 79 insertions(+), 57 deletions(-) diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index cf0701e0..bea5f408 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -72,9 +72,6 @@ SETTINGS_TEMPLATE = { "format.replaceTabs": (bool, False), "odt.addColours": (bool, True), "html.addStyles": (bool, False), - "build.splitNovel": (bool, False), - "build.splitNotes": (bool, False), - "build.splitChapters": (bool, False), } SETTINGS_LABELS = { @@ -115,11 +112,6 @@ SETTINGS_LABELS = { "html": QT_TRANSLATE_NOOP("Builds", "HTML"), "html.addStyles": QT_TRANSLATE_NOOP("Builds", "Add CSS Styles"), - - "build": QT_TRANSLATE_NOOP("Builds", "Build Options"), - "build.splitNovel": QT_TRANSLATE_NOOP("Builds", "Split Novel Root Folders"), - "build.splitNotes": QT_TRANSLATE_NOOP("Builds", "Split Note Root Folders"), - "build.splitChapters": QT_TRANSLATE_NOOP("Builds", "Split By Chapter Document"), } diff --git a/novelwriter/core/options.py b/novelwriter/core/options.py index 89cb6ab9..9a1c0af8 100644 --- a/novelwriter/core/options.py +++ b/novelwriter/core/options.py @@ -64,7 +64,7 @@ VALID_MAP = { "winWidth", "winHeight", "optsWidth", "viewWidth" }, "GuiManuscriptBuild": { - "winWidth", "winHeight", "fmtWidth", "optsWidth" + "winWidth", "winHeight", "fmtWidth", "sumWidth" }, } diff --git a/novelwriter/tools/manusbuild.py b/novelwriter/tools/manusbuild.py index 77d04a71..875a50b1 100644 --- a/novelwriter/tools/manusbuild.py +++ b/novelwriter/tools/manusbuild.py @@ -27,9 +27,9 @@ import logging from typing import TYPE_CHECKING -from PyQt5.QtCore import Qt, pyqtSlot +from PyQt5.QtCore import QSize, Qt, pyqtSlot from PyQt5.QtWidgets import ( - QAbstractButton, QDialog, QDialogButtonBox, QFrame, QGridLayout, + QAbstractButton, QAbstractItemView, QDialog, QDialogButtonBox, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QProgressBar, QPushButton, QSplitter, QVBoxLayout, QWidget ) @@ -37,8 +37,8 @@ from PyQt5.QtWidgets import ( from novelwriter import CONFIG from novelwriter.common import makeFileNameSafe from novelwriter.constants import nwLabels +from novelwriter.core.item import NWItem from novelwriter.core.buildsettings import BuildSettings -from novelwriter.extensions.switchbox import NSwitchBox if TYPE_CHECKING: from novelwriter.guimain import GuiMain @@ -70,8 +70,9 @@ class GuiManuscriptBuild(QDialog): self.setWindowTitle(self.tr("Build Manuscript")) self.setMinimumWidth(CONFIG.pxInt(500)) - self.setMinimumHeight(CONFIG.pxInt(250)) + self.setMinimumHeight(CONFIG.pxInt(300)) + iPx = self.mainTheme.baseIconSize wWin = CONFIG.pxInt(660) hWin = CONFIG.pxInt(390) @@ -81,11 +82,12 @@ class GuiManuscriptBuild(QDialog): CONFIG.pxInt(pOptions.getInt("GuiManuscriptBuild", "winHeight", hWin)) ) - # Formats - # ======= + # Output Format + # ============= - self.lblFormat = QLabel("{0}".format(self.tr("Build Format"))) + self.lblFormat = QLabel(self.tr("Output Format")) self.listFormats = QListWidget() + self.listFormats.setIconSize(QSize(iPx, iPx)) current = None for key, (_, label) in nwLabels.BUILD_FORMATS.items(): item = QListWidgetItem() @@ -106,28 +108,23 @@ class GuiManuscriptBuild(QDialog): self.formatWidget.setLayout(self.formatBox) self.formatWidget.setContentsMargins(0, 0, 0, 0) - # Build Controls - # ============== + # Table of Contents + # ================= - # Build Options - self.swtOptions = NSwitchBox(self, self.mainTheme.baseIconSize) - self.swtOptions.switchToggled.connect(self._applyBuildOptions) - self.swtOptions.setFrameStyle(QFrame.NoFrame) - self.swtOptions.setInnerContentsMargins(0, 0, 0, 0) + self.lblContent = QLabel(self.tr("Table of Contents")) - self.swtOptions.addLabel(self._build.getLabel("build")) - self.swtOptions.addItem( - self.mainTheme.getIcon("cls_novel"), self._build.getLabel("build.splitNovel"), - "build.splitNovel", default=self._build.getBool("build.splitNovel") - ) - self.swtOptions.addItem( - self.mainTheme.getIcon("cls_custom"), self._build.getLabel("build.splitNotes"), - "build.splitNotes", default=self._build.getBool("build.splitNotes") - ) - self.swtOptions.addItem( - self.mainTheme.getIcon("proj_chapter"), self._build.getLabel("build.splitChapters"), - "build.splitChapters", default=self._build.getBool("build.splitChapters") - ) + self.listContent = QListWidget(self) + self.listContent.setIconSize(QSize(iPx, iPx)) + self.listContent.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) + + self.contentBox = QVBoxLayout() + self.contentBox.addWidget(self.lblContent, 0) + self.contentBox.addWidget(self.listContent, 0) + self.contentBox.setContentsMargins(0, 0, 0, 0) + + self.contentWidget = QWidget() + self.contentWidget.setLayout(self.contentBox) + self.contentWidget.setContentsMargins(0, 0, 0, 0) # Dialog Controls # =============== @@ -168,6 +165,7 @@ class GuiManuscriptBuild(QDialog): # Build Progress self.lblProgress = QLabel(self.tr("Build Progress")) self.buildProgress = QProgressBar() + self.buildProgress.setValue(0) # Dialog Buttons self.btnBuild = QPushButton(self.mainTheme.getIcon("export"), self.tr("&Build")) @@ -180,13 +178,13 @@ class GuiManuscriptBuild(QDialog): self.mainSplit = QSplitter() self.mainSplit.addWidget(self.formatWidget) - self.mainSplit.addWidget(self.swtOptions) + self.mainSplit.addWidget(self.contentWidget) self.mainSplit.setHandleWidth(CONFIG.pxInt(16)) self.mainSplit.setCollapsible(0, False) self.mainSplit.setCollapsible(1, False) self.mainSplit.setSizes([ - CONFIG.pxInt(pOptions.getInt("GuiManuscriptBuild", "fmtWidth", int(0.45*wWin))), - CONFIG.pxInt(pOptions.getInt("GuiManuscriptBuild", "optsWidth", int(0.55*wWin))), + CONFIG.pxInt(pOptions.getInt("GuiManuscriptBuild", "fmtWidth", wWin//2)), + CONFIG.pxInt(pOptions.getInt("GuiManuscriptBuild", "sumWidth", wWin//2)), ]) self.outerBox = QGridLayout() @@ -200,6 +198,7 @@ class GuiManuscriptBuild(QDialog): self.outerBox.addWidget(self.lblProgress, 5, 0, 1, 1) self.outerBox.addWidget(self.buildProgress, 5, 1, 1, 1) self.outerBox.addWidget(self.dlgButtons, 6, 0, 1, 2) + self.outerBox.setRowStretch(2, 1) self.setLayout(self.outerBox) @@ -209,6 +208,7 @@ class GuiManuscriptBuild(QDialog): self._doResetBuildName() self.btnBuild.setFocus() + self._populateContentList() logger.debug("Ready: GuiManuscriptBuild") @@ -294,16 +294,45 @@ class GuiManuscriptBuild(QDialog): winHeight = CONFIG.rpxInt(self.height()) mainSplit = self.mainSplit.sizes() - fmtWidth = CONFIG.rpxInt(mainSplit[0]) - optsWidth = CONFIG.rpxInt(mainSplit[1]) + fmtWidth = CONFIG.rpxInt(mainSplit[0]) + sumWidth = CONFIG.rpxInt(mainSplit[1]) pOptions = self.theProject.options pOptions.setValue("GuiManuscriptBuild", "winWidth", winWidth) pOptions.setValue("GuiManuscriptBuild", "winHeight", winHeight) pOptions.setValue("GuiManuscriptBuild", "fmtWidth", fmtWidth) - pOptions.setValue("GuiManuscriptBuild", "optsWidth", optsWidth) + pOptions.setValue("GuiManuscriptBuild", "sumWidth", sumWidth) pOptions.saveSettings() return + def _populateContentList(self): + """Build the content list.""" + rootMap = {} + filtered = self._build.buildItemFilter(self.theProject) + self.listContent.clear() + for nwItem in self.theProject.tree: + tHandle = nwItem.itemHandle + rHandle = nwItem.itemRoot + + if tHandle is None or rHandle is None or not nwItem.isFileType(): + continue + + if filtered.get(tHandle, (False, 0))[0]: + if rHandle not in rootMap: + rItem = self.theProject.tree[rHandle] + if isinstance(rItem, NWItem): + rootMap[rHandle] = rItem.itemName + + itemIcon = self.mainTheme.getItemIcon( + nwItem.itemType, nwItem.itemClass, + nwItem.itemLayout, nwItem.mainHeading + ) + rootName = rootMap.get(rHandle, "??????") + item = QListWidgetItem(f"{rootName}: {nwItem.itemName}") + item.setIcon(itemIcon) + self.listContent.addItem(item) + + return + # END Class GuiManuscriptBuild diff --git a/novelwriter/tools/manuscript.py b/novelwriter/tools/manuscript.py index fbdbac8e..51bd3121 100644 --- a/novelwriter/tools/manuscript.py +++ b/novelwriter/tools/manuscript.py @@ -144,9 +144,12 @@ class GuiManuscript(QDialog): # ================ self.buildProgress = QProgressBar() + self.buildProgress.setValue(0) + self.btnPreview = QPushButton(self.tr("Build Preview")) self.btnPreview.clicked.connect(self._generatePreview) - self.manPreview = _PreviewWidget(self.mainGui) + + self.docPreview = _PreviewWidget(self.mainGui) self.btnBuild = QPushButton(self.tr("Build")) self.btnBuild.clicked.connect(self._buildManuscript) @@ -182,7 +185,7 @@ class GuiManuscript(QDialog): self.mainSplit = QSplitter() self.mainSplit.addWidget(self.optsWidget) - self.mainSplit.addWidget(self.manPreview) + self.mainSplit.addWidget(self.docPreview) self.mainSplit.setSizes([ CONFIG.pxInt(pOptions.getInt("GuiManuscript", "optsWidth", wWin//3)), CONFIG.pxInt(pOptions.getInt("GuiManuscript", "viewWidth", 2*wWin//3)), @@ -338,13 +341,13 @@ class GuiManuscript(QDialog): def _updatePreview(self, data: dict, build: BuildSettings): """Update the preview widget and set relevant values.""" - self.manPreview.setContent(data) - self.manPreview.setBuildName(build.name) - self.manPreview.setTextFont( + self.docPreview.setContent(data) + self.docPreview.setBuildName(build.name) + self.docPreview.setTextFont( build.getStr("format.textFont"), build.getInt("format.textSize") ) - self.manPreview.setJustify( + self.docPreview.setJustify( build.getBool("format.justifyText") ) return diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index a831f650..53da155d 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -435,11 +435,15 @@ class _FilterTab(QWidget): logger.debug("Building project tree") self._treeMap = {} self.optTree.clear() - for nwItem in self.theProject.getProjectItems(): + for nwItem in self.theProject.tree: tHandle = nwItem.itemHandle pHandle = nwItem.itemParent rHandle = nwItem.itemRoot + + if tHandle is None or rHandle is None: + continue + isFile = nwItem.isFileType() isActive = nwItem.isActive @@ -465,16 +469,10 @@ class _FilterTab(QWidget): trItem.setTextAlignment(self.C_NAME, Qt.AlignLeft) - if pHandle is None: - if nwItem.isRootType(): - self.optTree.addTopLevelItem(trItem) - else: - logger.debug("Skipping item '%s'", tHandle) - continue - + if pHandle is None and nwItem.isRootType(): + self.optTree.addTopLevelItem(trItem) elif pHandle in self._treeMap: self._treeMap[pHandle].addChild(trItem) - else: logger.debug("Skipping item '%s'", tHandle) continue From 9bc66128a7e3d6f4921185164b4b53a89c1c5d4a Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Mon, 5 Jun 2023 23:39:28 +0200 Subject: [PATCH 62/74] Complete the build process in the build tool --- novelwriter/constants.py | 16 ++-- novelwriter/core/buildsettings.py | 8 +- novelwriter/core/docbuild.py | 111 +++++++++++++++++++------- novelwriter/core/index.py | 3 +- novelwriter/tools/manusbuild.py | 109 +++++++++++-------------- tests/test_core/test_core_docbuild.py | 21 +++-- 6 files changed, 156 insertions(+), 112 deletions(-) diff --git a/novelwriter/constants.py b/novelwriter/constants.py index e15b6733..48d10f51 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -206,14 +206,14 @@ class nwLabels: nwOutline.SYNOP: QT_TRANSLATE_NOOP("Constant", "Synopsis"), } BUILD_FORMATS = { - "odt": ("odt", QT_TRANSLATE_NOOP("Constant", "Open Document (.odt)")), - "fodt": ("fodt", QT_TRANSLATE_NOOP("Constant", "Flat Open Document (.fodt)")), - "html": ("html", QT_TRANSLATE_NOOP("Constant", "novelWriter HTML (.html)")), - "nwd": ("nwd", QT_TRANSLATE_NOOP("Constant", "novelWriter Markdown (.nwd)")), - "md": ("md", QT_TRANSLATE_NOOP("Constant", "Standard Markdown (.md)")), - "md+": ("md", QT_TRANSLATE_NOOP("Constant", "Extended Markdown (.md)")), - "jhtml": ("json", QT_TRANSLATE_NOOP("Constant", "JSON + novelWriter HTML (.json)")), - "jnwd": ("json", QT_TRANSLATE_NOOP("Constant", "JSON + novelWriter Markdown (.json)")), + "odt": (".odt", QT_TRANSLATE_NOOP("Constant", "Open Document (.odt)")), + "fodt": (".fodt", QT_TRANSLATE_NOOP("Constant", "Flat Open Document (.fodt)")), + "html": (".html", QT_TRANSLATE_NOOP("Constant", "novelWriter HTML (.html)")), + "nwd": (".nwd", QT_TRANSLATE_NOOP("Constant", "novelWriter Markdown (.nwd)")), + "md": (".md", QT_TRANSLATE_NOOP("Constant", "Standard Markdown (.md)")), + "md+": (".md", QT_TRANSLATE_NOOP("Constant", "Extended Markdown (.md)")), + "jhtml": (".json", QT_TRANSLATE_NOOP("Constant", "JSON + novelWriter HTML (.json)")), + "jnwd": (".json", QT_TRANSLATE_NOOP("Constant", "JSON + novelWriter Markdown (.json)")), } # END Class nwLabels diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index bea5f408..1d6606ee 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -123,6 +123,7 @@ class FilterMode(Enum): INCLUDED = 2 EXCLUDED = 3 SKIPPED = 4 + ROOT = 5 # END Enum FilterMode @@ -311,7 +312,9 @@ class BuildSettings: """Check if a root handle is allowed in the build.""" return tHandle not in self._skipRoot - def buildItemFilter(self, project: NWProject) -> dict[str, tuple[bool, FilterMode]]: + def buildItemFilter( + self, project: NWProject, withRoots: bool = False + ) -> dict[str, tuple[bool, FilterMode]]: """Return a dictionary of item handles with filter decissions applied. """ @@ -333,6 +336,9 @@ class BuildSettings: if item.isInactiveClass() or (item.itemRoot in self._skipRoot): result[tHandle] = (False, FilterMode.SKIPPED) continue + if withRoots and item.isRootType(): + result[tHandle] = (True, FilterMode.ROOT) + continue if not item.isFileType(): result[tHandle] = (False, FilterMode.SKIPPED) continue diff --git a/novelwriter/core/docbuild.py b/novelwriter/core/docbuild.py index 2270f708..74283540 100644 --- a/novelwriter/core/docbuild.py +++ b/novelwriter/core/docbuild.py @@ -106,25 +106,41 @@ class NWBuildDocument: self._queue.append(item.itemHandle) return + def iterBuild(self, path: Path, bFormat: str) -> Iterable[tuple[int, bool]]: + """Wrapper for builder based on format.""" + if bFormat in ("odt", "fodt"): + yield from self.iterBuildOpenDocument(path, bFormat == "fodt") + elif bFormat in ("html", "jhtml"): + yield from self.iterBuildHTML(path if bFormat == "html" else None) + elif bFormat in ("md", "md+"): + yield from self.iterBuildMarkdown(path, bFormat == "md+") + elif bFormat in ("nwd", "jnwd"): + yield from self.iterBuildNovelWriter(path if bFormat == "nwd" else None) + return + def iterBuildOpenDocument(self, path: Path, isFlat: bool) -> Iterable[tuple[int, bool]]: """Build an Open Document file.""" - makeOdt = ToOdt(self._project, isFlat=isFlat) - self._setupBuild(makeOdt) - makeOdt.initDocument() + makeObj = ToOdt(self._project, isFlat=isFlat) + filtered = self._setupBuild(makeObj) + makeObj.initDocument() for i, tHandle in enumerate(self._queue): - yield i, self._doBuild(makeOdt, tHandle) + self._error = None + if filtered.get(tHandle, (False, 0))[0]: + yield i, self._doBuild(makeObj, tHandle) + else: + yield i, False - makeOdt.closeDocument() + makeObj.closeDocument() self._error = None - self._cache = makeOdt + self._cache = makeObj try: if isFlat: - makeOdt.saveFlatXML(path) + makeObj.saveFlatXML(path) else: - makeOdt.saveOpenDocText(path) + makeObj.saveOpenDocText(path) except Exception as exc: self._error = formatException(exc) @@ -134,21 +150,25 @@ class NWBuildDocument: """Build an HTML file. If path is None, no file is saved. This is used for generating build previews. """ - makeHtml = ToHtml(self._project) - self._setupBuild(makeHtml) + makeObj = ToHtml(self._project) + filtered = self._setupBuild(makeObj) if self._build.getBool("format.replaceTabs"): - makeHtml.replaceTabs() + makeObj.replaceTabs() for i, tHandle in enumerate(self._queue): - yield i, self._doBuild(makeHtml, tHandle) + self._error = None + if filtered.get(tHandle, (False, 0))[0]: + yield i, self._doBuild(makeObj, tHandle) + else: + yield i, False self._error = None - self._cache = makeHtml + self._cache = makeObj if isinstance(path, Path): try: - makeHtml.saveHTML5(path) + makeObj.saveHTML5(path) except Exception as exc: self._error = formatException(exc) @@ -156,35 +176,66 @@ class NWBuildDocument: def iterBuildMarkdown(self, path: Path, extendedMd: bool) -> Iterable[tuple[int, bool]]: """Build a Markdown file.""" - makeMd = ToMarkdown(self._project) - self._setupBuild(makeMd) + makeObj = ToMarkdown(self._project) + filtered = self._setupBuild(makeObj) if extendedMd: - makeMd.setGitHubMarkdown() + makeObj.setGitHubMarkdown() else: - makeMd.setStandardMarkdown() + makeObj.setStandardMarkdown() if self._build.getBool("format.replaceTabs"): - makeMd.replaceTabs(nSpaces=4, spaceChar=" ") + makeObj.replaceTabs(nSpaces=4, spaceChar=" ") for i, tHandle in enumerate(self._queue): - yield i, self._doBuild(makeMd, tHandle) + self._error = None + if filtered.get(tHandle, (False, 0))[0]: + yield i, self._doBuild(makeObj, tHandle) + else: + yield i, False self._error = None - self._cache = makeMd + self._cache = makeObj try: - makeMd.saveMarkdown(path) + makeObj.saveMarkdown(path) except Exception as exc: self._error = formatException(exc) return + def iterBuildNovelWriter(self, path: Path | None) -> Iterable[tuple[int, bool]]: + """Build a novelWriter Markdown file.""" + makeObj = ToMarkdown(self._project) + filtered = self._setupBuild(makeObj) + + makeObj.setKeepMarkdown(True) + if self._build.getBool("format.replaceTabs"): + makeObj.replaceTabs(nSpaces=4, spaceChar=" ") + + for i, tHandle in enumerate(self._queue): + self._error = None + if filtered.get(tHandle, (False, 0))[0]: + yield i, self._doBuild(makeObj, tHandle, convert=False) + else: + yield i, False + + self._error = None + self._cache = makeObj + + if isinstance(path, Path): + try: + makeObj.saveRawMarkdown(path) + except Exception as exc: + self._error = formatException(exc) + + return + ## # Internal Functions ## - def _setupBuild(self, bldObj: Tokenizer): + def _setupBuild(self, bldObj: Tokenizer) -> dict: """Configure the build object.""" # Get Settings fmtTitle = self._build.getStr("headings.fmtTitle") @@ -199,6 +250,7 @@ class NWBuildDocument: incComments = self._build.getBool("text.includeComments") incKeywords = self._build.getBool("text.includeKeywords") incBodyText = self._build.getBool("text.includeBodyText") + noteHeadings = self._build.getBool("text.addNoteHeadings") buildLang = self._build.getStr("format.buildLang") textFont = self._build.getStr("format.textFont") @@ -244,11 +296,12 @@ class NWBuildDocument: bldObj.setColourHeaders(odtAddColours) bldObj.setLanguage(buildLang) - return + filtered = self._build.buildItemFilter(self._project, withRoots=noteHeadings) - def _doBuild(self, bldObj: Tokenizer, tHandle: str) -> bool: + return filtered + + def _doBuild(self, bldObj: Tokenizer, tHandle: str, convert: bool = True) -> bool: """Build a single document and add it to the build object.""" - self._error = None tItem = self._project.tree[tHandle] if tItem is None: self._error = f"Build: Unknown item '{tHandle}'" @@ -258,13 +311,15 @@ class NWBuildDocument: try: if tItem.isRootType() and not tItem.isNovelLike(): bldObj.addRootHeading(tItem.itemHandle) - bldObj.doConvert() + if convert: + bldObj.doConvert() elif tItem.isFileType(): bldObj.setText(tHandle) bldObj.doPreProcessing() bldObj.tokenizeText() bldObj.doHeaders() - bldObj.doConvert() + if convert: + bldObj.doConvert() else: logger.info(f"Build: Skipping '{tHandle}'") diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index 920eef22..71ff9814 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -786,8 +786,7 @@ class ItemIndex: """Iterate over all item headers of an item. """ if tHandle in self._items: - for sTitle, hItem in self._items[tHandle].items(): - yield sTitle, hItem + yield from self._items[tHandle].items() return def iterAllHeaders(self): diff --git a/novelwriter/tools/manusbuild.py b/novelwriter/tools/manusbuild.py index 875a50b1..7412658a 100644 --- a/novelwriter/tools/manusbuild.py +++ b/novelwriter/tools/manusbuild.py @@ -24,19 +24,21 @@ along with this program. If not, see . from __future__ import annotations import logging +from pathlib import Path from typing import TYPE_CHECKING from PyQt5.QtCore import QSize, Qt, pyqtSlot from PyQt5.QtWidgets import ( - QAbstractButton, QAbstractItemView, QDialog, QDialogButtonBox, QGridLayout, - QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QProgressBar, - QPushButton, QSplitter, QVBoxLayout, QWidget + QAbstractButton, QAbstractItemView, QDialog, QDialogButtonBox, QFileDialog, + QLabel, QListWidget, QListWidgetItem, QProgressBar, QPushButton, QSplitter, + QVBoxLayout, QWidget ) from novelwriter import CONFIG from novelwriter.common import makeFileNameSafe from novelwriter.constants import nwLabels +from novelwriter.core.docbuild import NWBuildDocument from novelwriter.core.item import NWItem from novelwriter.core.buildsettings import BuildSettings @@ -70,11 +72,11 @@ class GuiManuscriptBuild(QDialog): self.setWindowTitle(self.tr("Build Manuscript")) self.setMinimumWidth(CONFIG.pxInt(500)) - self.setMinimumHeight(CONFIG.pxInt(300)) + self.setMinimumHeight(CONFIG.pxInt(250)) iPx = self.mainTheme.baseIconSize wWin = CONFIG.pxInt(660) - hWin = CONFIG.pxInt(390) + hWin = CONFIG.pxInt(360) pOptions = self.theProject.options self.resize( @@ -138,35 +140,18 @@ class GuiManuscriptBuild(QDialog): self.lblMain.setWordWrap(True) self.lblMain.setFont(font) - self.spaceWidget = QWidget() - self.spaceWidget.setFixedHeight(CONFIG.pxInt(8)) - - # Build Path - self.lblPath = QLabel(self.tr("Build Folder")) - self.buildPath = QLineEdit() - self.buildPath.setText(str(self._build.lastPath)) - self.btnBrowse = QPushButton(self.mainTheme.getIcon("browse"), "") - - self.pathBox = QHBoxLayout() - self.pathBox.addWidget(self.buildPath) - self.pathBox.addWidget(self.btnBrowse) - - # Build Name - self.lblName = QLabel(self.tr("Build Name")) - self.buildName = QLineEdit() - self.btnReset = QPushButton(self.mainTheme.getIcon("revert"), "") - self.btnReset.setToolTip(self.tr("Reset Build Name to default")) - self.btnReset.clicked.connect(self._doResetBuildName) - - self.nameBox = QHBoxLayout() - self.nameBox.addWidget(self.buildName) - self.nameBox.addWidget(self.btnReset) - # Build Progress self.lblProgress = QLabel(self.tr("Build Progress")) + self.buildProgress = QProgressBar() + self.buildProgress.setMinimum(0) self.buildProgress.setValue(0) + self.progressBox = QVBoxLayout() + self.progressBox.addWidget(self.lblProgress) + self.progressBox.addWidget(self.buildProgress) + self.progressBox.setSpacing(CONFIG.pxInt(4)) + # Dialog Buttons self.btnBuild = QPushButton(self.mainTheme.getIcon("export"), self.tr("&Build")) self.dlgButtons = QDialogButtonBox(QDialogButtonBox.Close) @@ -187,26 +172,15 @@ class GuiManuscriptBuild(QDialog): CONFIG.pxInt(pOptions.getInt("GuiManuscriptBuild", "sumWidth", wWin//2)), ]) - self.outerBox = QGridLayout() - self.outerBox.addWidget(self.lblMain, 0, 0, 1, 2, Qt.AlignCenter) - self.outerBox.addWidget(self.spaceWidget, 1, 0, 1, 2) - self.outerBox.addWidget(self.mainSplit, 2, 0, 1, 2) - self.outerBox.addWidget(self.lblPath, 3, 0, 1, 1) - self.outerBox.addLayout(self.pathBox, 3, 1, 1, 1) - self.outerBox.addWidget(self.lblName, 4, 0, 1, 1) - self.outerBox.addLayout(self.nameBox, 4, 1, 1, 1) - self.outerBox.addWidget(self.lblProgress, 5, 0, 1, 1) - self.outerBox.addWidget(self.buildProgress, 5, 1, 1, 1) - self.outerBox.addWidget(self.dlgButtons, 6, 0, 1, 2) - self.outerBox.setRowStretch(2, 1) + self.outerBox = QVBoxLayout() + self.outerBox.addWidget(self.lblMain, 0, Qt.AlignCenter) + self.outerBox.addWidget(self.mainSplit, 1) + self.outerBox.addLayout(self.progressBox, 0) + self.outerBox.addWidget(self.dlgButtons, 0) + self.outerBox.setSpacing(CONFIG.pxInt(12)) self.setLayout(self.outerBox) - if self._build.lastBuildName: - self.buildName.setText(makeFileNameSafe(self._build.lastBuildName)) - else: - self._doResetBuildName() - self.btnBuild.setFocus() self._populateContentList() @@ -252,30 +226,43 @@ class GuiManuscriptBuild(QDialog): self.close() return - @pyqtSlot() - def _doResetBuildName(self): - """Generate a default build name.""" - bName = makeFileNameSafe(f"{self.theProject.data.name} - {self._build.name}") - self.buildName.setText(bName) - self._build.setLastBuildName(bName) - return - ## # Internal Functions ## def _runBuild(self) -> bool: """Run the currently selected build.""" - bFormat = self._getSelectedFormat() - if not bFormat: + selFormat = self._getSelectedFormat() + if not selFormat or selFormat not in nwLabels.BUILD_FORMATS: return False - bPath = self.buildPath.text() - bName = self.buildName.text() + lastName = self._build.lastBuildName + if not lastName: + lastName = f"{self.theProject.data.name} - {self._build.name}.ext" - self._build.setLastFormat(bFormat) - self._build.setLastPath(bPath) - self._build.setLastBuildName(bName) + lastPath = self._build.lastPath + selExt = nwLabels.BUILD_FORMATS[selFormat][0] + selName = Path(makeFileNameSafe(lastName)).with_suffix(selExt) + + self.buildProgress.setValue(0) + savePath, _ = QFileDialog.getSaveFileName( + self, self.tr("Save Manuscript As"), str(lastPath / selName) + ) + if not savePath: + return False + + buildPath = Path(savePath) + + docBuild = NWBuildDocument(self.theProject, self._build) + docBuild.queueAll() + + self.buildProgress.setMaximum(len(docBuild)) + for i, _ in docBuild.iterBuild(buildPath, selFormat): + self.buildProgress.setValue(i+1) + + self._build.setLastFormat(selFormat) + self._build.setLastPath(buildPath.parent) + self._build.setLastBuildName(buildPath.name) return True diff --git a/tests/test_core/test_core_docbuild.py b/tests/test_core/test_core_docbuild.py index 5953a10a..380d1891 100644 --- a/tests/test_core/test_core_docbuild.py +++ b/tests/test_core/test_core_docbuild.py @@ -36,7 +36,7 @@ BUILD_CONF = { "settings": { "filter.includeNovel": True, "filter.includeNotes": True, - "filter.includeInactive": False, + "filter.includeInactive": True, "headings.fmtTitle": "Title: {Title}", "headings.fmtChapter": "Chapter: {Title}", "headings.fmtUnnumbered": "{Title}", @@ -96,7 +96,7 @@ def testCoreDocBuild_OpenDocument(monkeypatch, mockGUI, prjLipsum, fncPath, tstP if docBuild.error: error.append(docBuild.error) - assert count == 21 + assert count == 19 assert error == [] copyfile(docFile, tstFile) @@ -114,7 +114,7 @@ def testCoreDocBuild_OpenDocument(monkeypatch, mockGUI, prjLipsum, fncPath, tstP if docBuild.error: error.append(docBuild.error) - assert count == 21 + assert count == 19 assert error == [] assert docFile.is_file() @@ -137,19 +137,17 @@ def testCoreDocBuild_OpenDocument(monkeypatch, mockGUI, prjLipsum, fncPath, tstP with monkeypatch.context() as mp: mp.setattr("novelwriter.core.toodt.ToOdt.doConvert", causeException) - - docBuild.addDocument("0000000000000") - assert len(docBuild) == 22 + assert len(docBuild) == 21 count = 0 error = [] docFile = fncPath / "Lorem Ipsum Err.fodt" for _, success in docBuild.iterBuildOpenDocument(docFile, True): count += 1 if success else 0 - if docBuild.error: + if not success and docBuild.error: error.append(docBuild.error) - assert count == 3 + assert count == 1 assert error == [ "Build: Failed to build '7a992350f3eb6'", "Build: Failed to build '8c58a65414c23'", @@ -169,7 +167,6 @@ def testCoreDocBuild_OpenDocument(monkeypatch, mockGUI, prjLipsum, fncPath, tstP "Build: Failed to build '2426c6f0ca922'", "Build: Failed to build '60bdf227455cc'", "Build: Failed to build '04468803b92e1'", - "Build: Unknown item '0000000000000'", ] # END Test testCoreDocBuild_OpenDocument @@ -204,7 +201,7 @@ def testCoreDocBuild_HTML(monkeypatch, mockGUI, prjLipsum, fncPath, tstPaths): if docBuild.error: error.append(docBuild.error) - assert count == 21 + assert count == 19 assert error == [] copyfile(docFile, tstFile) @@ -255,7 +252,7 @@ def testCoreDocBuild_Markdown(monkeypatch, mockGUI, prjLipsum, fncPath, tstPaths if docBuild.error: error.append(docBuild.error) - assert count == 21 + assert count == 19 assert error == [] copyfile(docFile, tstFile) @@ -275,7 +272,7 @@ def testCoreDocBuild_Markdown(monkeypatch, mockGUI, prjLipsum, fncPath, tstPaths if docBuild.error: error.append(docBuild.error) - assert count == 21 + assert count == 19 assert error == [] copyfile(docFile, tstFile) From 67f1dd66772e78872f9eae79220457875864ed8a Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 6 Jun 2023 00:03:27 +0200 Subject: [PATCH 63/74] Clean up variables in doc builder --- novelwriter/core/docbuild.py | 75 +++++++++++++++--------------------- 1 file changed, 30 insertions(+), 45 deletions(-) diff --git a/novelwriter/core/docbuild.py b/novelwriter/core/docbuild.py index 74283540..125d47c5 100644 --- a/novelwriter/core/docbuild.py +++ b/novelwriter/core/docbuild.py @@ -107,7 +107,7 @@ class NWBuildDocument: return def iterBuild(self, path: Path, bFormat: str) -> Iterable[tuple[int, bool]]: - """Wrapper for builder based on format.""" + """Wrapper for builders based on format.""" if bFormat in ("odt", "fodt"): yield from self.iterBuildOpenDocument(path, bFormat == "fodt") elif bFormat in ("html", "jhtml"): @@ -238,65 +238,50 @@ class NWBuildDocument: def _setupBuild(self, bldObj: Tokenizer) -> dict: """Configure the build object.""" # Get Settings - fmtTitle = self._build.getStr("headings.fmtTitle") - fmtChapter = self._build.getStr("headings.fmtChapter") - fmtUnnumbered = self._build.getStr("headings.fmtUnnumbered") - fmtScene = self._build.getStr("headings.fmtScene") - fmtSection = self._build.getStr("headings.fmtSection") - hideScene = self._build.getBool("headings.hideScene") - hideSection = self._build.getBool("headings.hideSection") - - incSynopsis = self._build.getBool("text.includeSynopsis") - incComments = self._build.getBool("text.includeComments") - incKeywords = self._build.getBool("text.includeKeywords") - incBodyText = self._build.getBool("text.includeBodyText") - noteHeadings = self._build.getBool("text.addNoteHeadings") - - buildLang = self._build.getStr("format.buildLang") - textFont = self._build.getStr("format.textFont") - textSize = self._build.getInt("format.textSize") - lineHeight = self._build.getFloat("format.lineHeight") - justifyText = self._build.getBool("format.justifyText") - replaceUCode = self._build.getBool("format.stripUnicode") - - odtAddColours = self._build.getBool("odt.addColours") - htmlAddStyles = self._build.getBool("html.addStyles") + buildLang = self._build.getStr("format.buildLang") + textFont = self._build.getStr("format.textFont") + textSize = self._build.getInt("format.textSize") # The language lookup dict is reloaded if needed self._project.setProjectLang(buildLang) - # Get font information - if not textFont: - textFont = str(CONFIG.textFont) - - bldFont = QFont(textFont, textSize) + fontFamily = textFont or CONFIG.textFont + bldFont = QFont(fontFamily, textSize) fontInfo = QFontInfo(bldFont) textFixed = fontInfo.fixedPitch() - bldObj.setTitleFormat(fmtTitle) - bldObj.setChapterFormat(fmtChapter) - bldObj.setUnNumberedFormat(fmtUnnumbered) - bldObj.setSceneFormat(fmtScene, hideScene) - bldObj.setSectionFormat(fmtSection, hideSection) + bldObj.setTitleFormat(self._build.getStr("headings.fmtTitle")) + bldObj.setChapterFormat(self._build.getStr("headings.fmtChapter")) + bldObj.setUnNumberedFormat(self._build.getStr("headings.fmtUnnumbered")) + bldObj.setSceneFormat( + self._build.getStr("headings.fmtScene"), + self._build.getBool("headings.hideScene") + ) + bldObj.setSectionFormat( + self._build.getStr("headings.fmtSection"), + self._build.getBool("headings.hideSection") + ) - bldObj.setFont(textFont, textSize, textFixed) - bldObj.setJustify(justifyText) - bldObj.setLineHeight(lineHeight) + bldObj.setFont(fontFamily, textSize, textFixed) + bldObj.setJustify(self._build.getBool("format.justifyText")) + bldObj.setLineHeight(self._build.getFloat("format.lineHeight")) - bldObj.setSynopsis(incSynopsis) - bldObj.setComments(incComments) - bldObj.setKeywords(incKeywords) - bldObj.setBodyText(incBodyText) + bldObj.setSynopsis(self._build.getBool("text.includeSynopsis")) + bldObj.setComments(self._build.getBool("text.includeComments")) + bldObj.setKeywords(self._build.getBool("text.includeKeywords")) + bldObj.setBodyText(self._build.getBool("text.includeBodyText")) if isinstance(bldObj, ToHtml): - bldObj.setStyles(htmlAddStyles) - bldObj.setReplaceUnicode(replaceUCode) + bldObj.setStyles(self._build.getBool("html.addStyles")) + bldObj.setReplaceUnicode(self._build.getBool("format.stripUnicode")) if isinstance(bldObj, ToOdt): - bldObj.setColourHeaders(odtAddColours) + bldObj.setColourHeaders(self._build.getBool("odt.addColours")) bldObj.setLanguage(buildLang) - filtered = self._build.buildItemFilter(self._project, withRoots=noteHeadings) + filtered = self._build.buildItemFilter( + self._project, withRoots=self._build.getBool("text.addNoteHeadings") + ) return filtered From 850ee73ed452bc92b04ba0d40192ba12fba486de Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 6 Jun 2023 00:03:46 +0200 Subject: [PATCH 64/74] Simplify core variables in tokenizer --- novelwriter/core/tohtml.py | 14 +- novelwriter/core/tokenizer.py | 132 +++++++------- novelwriter/core/tomd.py | 10 +- novelwriter/core/toodt.py | 20 +- tests/test_core/test_core_tohtml.py | 90 ++++----- tests/test_core/test_core_tokenizer.py | 242 ++++++++++++------------- tests/test_core/test_core_tomd.py | 42 ++--- tests/test_core/test_core_toodt.py | 42 ++--- 8 files changed, 295 insertions(+), 297 deletions(-) diff --git a/novelwriter/core/tohtml.py b/novelwriter/core/tohtml.py index cca296a6..51a9ef44 100644 --- a/novelwriter/core/tohtml.py +++ b/novelwriter/core/tohtml.py @@ -109,7 +109,7 @@ class ToHtml(Tokenizer): characters into their respective HTML entities. """ super().doPreProcessing() - self._theText = self._theText.translate(self._trMap) + self._text = self._text.translate(self._trMap) return def doConvert(self): @@ -149,13 +149,13 @@ class ToHtml(Tokenizer): h3 = "h3" h4 = "h4" - self._theResult = "" + self._result = "" thisPar = [] parStyle = None tmpResult = [] - for tType, tLine, tText, tFormat, tStyle in self._theTokens: + for tType, tLine, tText, tFormat, tStyle in self._tokens: # Replace < and > with HTML entities if tFormat: @@ -282,11 +282,11 @@ class ToHtml(Tokenizer): tTemp = f"{self._formatKeywords(tText)}

\n" tmpResult.append(tTemp) - self._theResult = "".join(tmpResult) + self._result = "".join(tmpResult) tmpResult = [] if self._genMode != self.M_PREVIEW: - self._fullHTML.append(self._theResult) + self._fullHTML.append(self._result) return @@ -316,7 +316,7 @@ class ToHtml(Tokenizer): "\n" "\n" ).format( - projTitle=self.theProject.data.name, + projTitle=self._project.data.name, htmlStyle="\n".join(theStyle), bodyText=bodyText, ) @@ -452,7 +452,7 @@ class ToHtml(Tokenizer): def _formatKeywords(self, tText): """Apply HTML formatting to keywords. """ - isValid, theBits, _ = self.theProject.index.scanThis("@"+tText) + isValid, theBits, _ = self._project.index.scanThis("@"+tText) if not isValid or not theBits: return "" diff --git a/novelwriter/core/tokenizer.py b/novelwriter/core/tokenizer.py index a978cff4..010695f0 100644 --- a/novelwriter/core/tokenizer.py +++ b/novelwriter/core/tokenizer.py @@ -91,16 +91,15 @@ class Tokenizer(ABC): A_IND_L = 0x0100 # Left indentation A_IND_R = 0x0200 # Right indentation - def __init__(self, theProject): + def __init__(self, project: NWProject): - self.theProject = theProject + self._project = project # Data Variables - self._theText = "" # The raw text to be tokenized - self._theHandle = None # The handle associated with the text - self._theItem = None # The NWItem associated with the handle - self._theTokens = [] # The list of the processed tokens - self._theResult = "" # The result of the last document + self._text = "" # The raw text to be tokenized + self._nwItem = None # The NWItem associated with the handle + self._tokens = [] # The list of the processed tokens + self._result = "" # The result of the last document self._keepMarkdown = False # Whether to keep the markdown text self._theMarkdown = [] # The result novelWriter markdown of all documents @@ -139,7 +138,7 @@ class Tokenizer(ABC): self._linkHeaders = False # Add an anchor before headers # Instance Variables - self._hFormatter = HeadingFormatter(self.theProject) + self._hFormatter = HeadingFormatter(self._project) self._firstScene = False # Flag to indicate that the first scene of the chapter # This File @@ -152,7 +151,7 @@ class Tokenizer(ABC): self._errData = [] # Function Mapping - self._localLookup = self.theProject.localLookup + self._localLookup = self._project.localLookup self.tr = partial(QCoreApplication.translate, "Tokenizer") # Cached Translations @@ -166,7 +165,7 @@ class Tokenizer(ABC): @property def theResult(self): - return self._theResult + return self._result @property def theMarkdown(self): @@ -283,7 +282,7 @@ class Tokenizer(ABC): def addRootHeading(self, theHandle): """Add a heading at the start of a new root folder. """ - if not self.theProject.tree.checkType(theHandle, nwItemType.ROOT): + if not self._project.tree.checkType(theHandle, nwItemType.ROOT): return False if self._isFirst: @@ -292,11 +291,11 @@ class Tokenizer(ABC): else: textAlign = self.A_PBB | self.A_CENTRE - theItem = self.theProject.tree[theHandle] + theItem = self._project.tree[theHandle] locNotes = self._localLookup("Notes") theTitle = f"{locNotes}: {theItem.itemName}" - self._theTokens = [] - self._theTokens.append(( + self._tokens = [] + self._tokens.append(( self.T_TITLE, 0, theTitle, None, textAlign )) if self._keepMarkdown: @@ -308,27 +307,26 @@ class Tokenizer(ABC): """Set the text for the tokenizer from a handle. If theText is not set, load it from the file. """ - self._theHandle = theHandle - self._theItem = self.theProject.tree[theHandle] - if self._theItem is None: + self._nwItem = self._project.tree[theHandle] + if self._nwItem is None: return False if theText is None: - theText = self.theProject.storage.getDocument(theHandle).readDocument() or "" + theText = self._project.storage.getDocument(theHandle).readDocument() or "" - self._theText = theText + self._text = theText - docSize = len(self._theText) + docSize = len(self._text) if docSize > nwConst.MAX_DOCSIZE: errVal = self.tr("Document '{0}' is too big ({1} MB). Skipping.").format( - self._theItem.itemName, f"{docSize/1.0e6:.2f}" + self._nwItem.itemName, f"{docSize/1.0e6:.2f}" ) - self._theText = "# {0}\n\n{1}\n\n".format(self.tr("ERROR"), errVal) + self._text = "# {0}\n\n{1}\n\n".format(self.tr("ERROR"), errVal) self._errData.append(errVal) - self._isNone = self._theItem.itemLayout == nwItemLayout.NO_LAYOUT - self._isNovel = self._theItem.itemLayout == nwItemLayout.DOCUMENT - self._isNote = self._theItem.itemLayout == nwItemLayout.NOTE + self._isNone = self._nwItem.itemLayout == nwItemLayout.NO_LAYOUT + self._isNovel = self._nwItem.itemLayout == nwItemLayout.DOCUMENT + self._isNote = self._nwItem.itemLayout == nwItemLayout.NOTE return True @@ -336,17 +334,17 @@ class Tokenizer(ABC): """Run trough the various replace doctionaries. """ # Process the user's auto-replace dictionary - autoReplace = self.theProject.data.autoReplace + autoReplace = self._project.data.autoReplace if len(autoReplace) > 0: repDict = {} for aKey, aVal in autoReplace.items(): repDict[f"<{aKey}>"] = aVal xRep = re.compile("|".join([re.escape(k) for k in repDict.keys()]), flags=re.DOTALL) - self._theText = xRep.sub(lambda x: repDict[x.group(0)], self._theText) + self._text = xRep.sub(lambda x: repDict[x.group(0)], self._text) # Process the character translation map trDict = {nwUnicode.U_MAPOSS: nwUnicode.U_RSQUO} - self._theText = self._theText.translate(str.maketrans(trDict)) + self._text = self._text.translate(str.maketrans(trDict)) return @@ -372,17 +370,17 @@ class Tokenizer(ABC): (QRegularExpression(nwRegEx.FMT_ST), [None, self.FMT_D_B, None, self.FMT_D_E]), ] - self._theTokens = [] + self._tokens = [] tmpMarkdown = [] nLine = 0 breakNext = False - for aLine in self._theText.splitlines(): + for aLine in self._text.splitlines(): nLine += 1 sLine = aLine.strip() # Check for blank lines if len(sLine) == 0: - self._theTokens.append(( + self._tokens.append(( self.T_EMPTY, nLine, "", None, self.A_NONE )) if self._keepMarkdown: @@ -407,7 +405,7 @@ class Tokenizer(ABC): continue elif sLine == "[VSPACE]": - self._theTokens.append( + self._tokens.append( (self.T_SKIP, nLine, "", None, sAlign) ) continue @@ -415,11 +413,11 @@ class Tokenizer(ABC): elif sLine.startswith("[VSPACE:") and sLine.endswith("]"): nSkip = checkInt(sLine[8:-1], 0) if nSkip >= 1: - self._theTokens.append( + self._tokens.append( (self.T_SKIP, nLine, "", None, sAlign) ) if nSkip > 1: - self._theTokens += (nSkip - 1) * [ + self._tokens += (nSkip - 1) * [ (self.T_SKIP, nLine, "", None, self.A_NONE) ] continue @@ -428,20 +426,20 @@ class Tokenizer(ABC): cLine = aLine[1:].lstrip() synTag = cLine[:9].lower() if synTag == "synopsis:": - self._theTokens.append(( + self._tokens.append(( self.T_SYNOPSIS, nLine, cLine[9:].strip(), None, sAlign )) if self._doSynopsis and self._keepMarkdown: tmpMarkdown.append("%s\n" % aLine) else: - self._theTokens.append(( + self._tokens.append(( self.T_COMMENT, nLine, aLine[1:].strip(), None, sAlign )) if self._doComments and self._keepMarkdown: tmpMarkdown.append("%s\n" % aLine) elif aLine[0] == "@": - self._theTokens.append(( + self._tokens.append(( self.T_KEYWORD, nLine, aLine[1:].strip(), None, sAlign )) if self._doKeywords and self._keepMarkdown: @@ -452,7 +450,7 @@ class Tokenizer(ABC): sAlign |= self.A_CENTRE sAlign |= self.A_PBB - self._theTokens.append(( + self._tokens.append(( self.T_HEAD1, nLine, aLine[2:].strip(), None, sAlign )) if self._keepMarkdown: @@ -462,21 +460,21 @@ class Tokenizer(ABC): if self._isNovel: sAlign |= self.A_PBB - self._theTokens.append(( + self._tokens.append(( self.T_HEAD2, nLine, aLine[3:].strip(), None, sAlign )) if self._keepMarkdown: tmpMarkdown.append("%s\n" % aLine) elif aLine[:4] == "### ": - self._theTokens.append(( + self._tokens.append(( self.T_HEAD3, nLine, aLine[4:].strip(), None, sAlign )) if self._keepMarkdown: tmpMarkdown.append("%s\n" % aLine) elif aLine[:5] == "#### ": - self._theTokens.append(( + self._tokens.append(( self.T_HEAD4, nLine, aLine[5:].strip(), None, sAlign )) if self._keepMarkdown: @@ -488,7 +486,7 @@ class Tokenizer(ABC): else: tStyle = self.T_HEAD1 - self._theTokens.append(( + self._tokens.append(( tStyle, nLine, aLine[3:].strip(), None, sAlign | self.A_CENTRE )) if self._keepMarkdown: @@ -501,7 +499,7 @@ class Tokenizer(ABC): else: tStyle = self.T_HEAD2 - self._theTokens.append(( + self._tokens.append(( tStyle, nLine, aLine[4:].strip(), None, sAlign )) if self._keepMarkdown: @@ -558,26 +556,26 @@ class Tokenizer(ABC): # Save the line as is, but append the array of formatting locations # sorted by position fmtPos = sorted(fmtPos, key=itemgetter(0)) - self._theTokens.append(( + self._tokens.append(( self.T_TEXT, nLine, aLine, fmtPos, sAlign )) if self._keepMarkdown: tmpMarkdown.append("%s\n" % aLine) # If we have content, turn off the first page flag - if self._isFirst and self._theTokens: + if self._isFirst and self._tokens: self._isFirst = False # Make sure the token array doesn't start with a page break # on the very first page, adding a blank first page. - if self._theTokens[0][4] & self.A_PBB: - tToken = self._theTokens[0] - self._theTokens[0] = ( + if self._tokens[0][4] & self.A_PBB: + tToken = self._tokens[0] + self._tokens[0] = ( tToken[0], tToken[1], tToken[2], tToken[3], tToken[4] & ~self.A_PBB ) # Always add an empty line at the end of the file - self._theTokens.append(( + self._tokens.append(( self.T_EMPTY, nLine, "", None, self.A_NONE )) if self._keepMarkdown: @@ -592,13 +590,13 @@ class Tokenizer(ABC): pToken = (self.T_EMPTY, 0, "", None, self.A_NONE) nToken = (self.T_EMPTY, 0, "", None, self.A_NONE) - tCount = len(self._theTokens) - for n, tToken in enumerate(self._theTokens): + tCount = len(self._tokens) + for n, tToken in enumerate(self._tokens): if n > 0: - pToken = self._theTokens[n-1] + pToken = self._tokens[n-1] if n < tCount - 1: - nToken = self._theTokens[n+1] + nToken = self._tokens[n+1] if tToken[0] == self.T_KEYWORD: aStyle = tToken[4] @@ -606,7 +604,7 @@ class Tokenizer(ABC): aStyle |= self.A_Z_TOPMRG if nToken[0] == self.T_KEYWORD: aStyle |= self.A_Z_BTMMRG - self._theTokens[n] = ( + self._tokens[n] = ( tToken[0], tToken[1], tToken[2], tToken[3], aStyle ) @@ -619,7 +617,7 @@ class Tokenizer(ABC): if not self._isNovel: return False - for n, tToken in enumerate(self._theTokens): + for n, tToken in enumerate(self._tokens): # In case we see text before a scene, we reset the flag if tToken[0] == self.T_TEXT: @@ -629,7 +627,7 @@ class Tokenizer(ABC): # Partition tTemp = self._hFormatter.apply(self._fmtTitle, tToken[2]) - self._theTokens[n] = ( + self._tokens[n] = ( tToken[0], tToken[1], tTemp, None, tToken[4] ) @@ -644,7 +642,7 @@ class Tokenizer(ABC): tTemp = self._hFormatter.apply(self._fmtChapter, tToken[2]) # Format the chapter header - self._theTokens[n] = ( + self._tokens[n] = ( tToken[0], tToken[1], tTemp, None, tToken[4] ) @@ -659,29 +657,29 @@ class Tokenizer(ABC): tTemp = self._hFormatter.apply(self._fmtScene, tToken[2]) if tTemp == "" and self._hideScene: - self._theTokens[n] = ( + self._tokens[n] = ( self.T_EMPTY, tToken[1], "", None, self.A_NONE ) elif tTemp == "" and not self._hideScene: if self._firstScene: - self._theTokens[n] = ( + self._tokens[n] = ( self.T_EMPTY, tToken[1], "", None, self.A_NONE ) else: - self._theTokens[n] = ( + self._tokens[n] = ( self.T_SKIP, tToken[1], "", None, tToken[4] ) elif tTemp == self._fmtScene: if self._firstScene: - self._theTokens[n] = ( + self._tokens[n] = ( self.T_EMPTY, tToken[1], "", None, self.A_NONE ) else: - self._theTokens[n] = ( + self._tokens[n] = ( self.T_SEP, tToken[1], tTemp, None, tToken[4] | self.A_CENTRE ) else: - self._theTokens[n] = ( + self._tokens[n] = ( tToken[0], tToken[1], tTemp, None, tToken[4] ) @@ -692,19 +690,19 @@ class Tokenizer(ABC): tTemp = self._hFormatter.apply(self._fmtSection, tToken[2]) if tTemp == "" and self._hideSection: - self._theTokens[n] = ( + self._tokens[n] = ( self.T_EMPTY, tToken[1], "", None, self.A_NONE ) elif tTemp == "" and not self._hideSection: - self._theTokens[n] = ( + self._tokens[n] = ( self.T_SKIP, tToken[1], "", None, tToken[4] ) elif tTemp == self._fmtSection: - self._theTokens[n] = ( + self._tokens[n] = ( self.T_SEP, tToken[1], tTemp, None, tToken[4] | self.A_CENTRE ) else: - self._theTokens[n] = ( + self._tokens[n] = ( tToken[0], tToken[1], tTemp, None, tToken[4] ) diff --git a/novelwriter/core/tomd.py b/novelwriter/core/tomd.py index e37c47e3..b59aed6f 100644 --- a/novelwriter/core/tomd.py +++ b/novelwriter/core/tomd.py @@ -98,12 +98,12 @@ class ToMarkdown(Tokenizer): self.FMT_D_E: "~~", } - self._theResult = "" + self._result = "" thisPar = [] tmpResult = [] - for tType, _, tText, tFormat, tStyle in self._theTokens: + for tType, _, tText, tFormat, tStyle in self._tokens: # Process Text Type if tType == self.T_EMPTY: @@ -159,10 +159,10 @@ class ToMarkdown(Tokenizer): elif tType == self.T_KEYWORD and self._doKeywords: tmpResult.append(self._formatKeywords(tText, tStyle)) - self._theResult = "".join(tmpResult) + self._result = "".join(tmpResult) tmpResult = [] - self._fullMD.append(self._theResult) + self._fullMD.append(self._result) return @@ -193,7 +193,7 @@ class ToMarkdown(Tokenizer): def _formatKeywords(self, tText, tStyle): """Apply Markdown formatting to keywords. """ - isValid, theBits, _ = self.theProject.index.scanThis("@"+tText) + isValid, theBits, _ = self._project.index.scanThis("@"+tText) if not isValid or not theBits: return "" diff --git a/novelwriter/core/toodt.py b/novelwriter/core/toodt.py index 40932ec2..b67d7a64 100644 --- a/novelwriter/core/toodt.py +++ b/novelwriter/core/toodt.py @@ -263,8 +263,8 @@ class ToOdt(Tokenizer): # =============== if self._headerText == "": - theTitle = self.theProject.data.title or self.theProject.data.name - theAuth = self.theProject.data.author + theTitle = self._project.data.title or self._project.data.name + theAuth = self._project.data.author self._headerText = f"{theTitle} / {theAuth} /" # Create Roots @@ -340,26 +340,26 @@ class ToOdt(Tokenizer): xMeta.text = f"novelWriter/{__version__}" xMeta = ET.SubElement(self._xMeta, _mkTag("meta", "initial-creator")) - xMeta.text = self.theProject.data.author + xMeta.text = self._project.data.author xMeta = ET.SubElement(self._xMeta, _mkTag("meta", "editing-cycles")) - xMeta.text = str(self.theProject.data.saveCount) + xMeta.text = str(self._project.data.saveCount) # Format is: PnYnMnDTnHnMnS # https://www.w3.org/TR/2004/REC-xmlschema-2-20041028/#duration - eT = self.theProject.data.editTime + eT = self._project.data.editTime xMeta = ET.SubElement(self._xMeta, _mkTag("meta", "editing-duration")) xMeta.text = f"P{eT//86400:d}DT{eT%86400//3600:d}H{eT%3600//60:d}M{eT%60:d}S" # Dublin Core Meta Data xMeta = ET.SubElement(self._xMeta, _mkTag("dc", "title")) - xMeta.text = self.theProject.data.title or self.theProject.data.name + xMeta.text = self._project.data.title or self._project.data.name xMeta = ET.SubElement(self._xMeta, _mkTag("dc", "date")) xMeta.text = timeStamp xMeta = ET.SubElement(self._xMeta, _mkTag("dc", "creator")) - xMeta.text = self.theProject.data.author + xMeta.text = self._project.data.author self._pageStyles() self._defaultStyles() @@ -371,7 +371,7 @@ class ToOdt(Tokenizer): def doConvert(self): """Convert the list of text tokens into XML elements. """ - self._theResult = "" # Not used, but cleared just in case + self._result = "" # Not used, but cleared just in case odtTags = { self.FMT_B_B: "_B", # Bold open format @@ -385,7 +385,7 @@ class ToOdt(Tokenizer): thisPar = [] thisFmt = [] parStyle = None - for tType, _, tText, tFormat, tStyle in self._theTokens: + for tType, _, tText, tFormat, tStyle in self._tokens: # Styles oStyle = ODTParagraphStyle() @@ -567,7 +567,7 @@ class ToOdt(Tokenizer): def _formatKeywords(self, tText): """Apply formatting to keywords. """ - isValid, theBits, _ = self.theProject.index.scanThis("@"+tText) + isValid, theBits, _ = self._project.index.scanThis("@"+tText) if not isValid or not theBits: return "" diff --git a/tests/test_core/test_core_tohtml.py b/tests/test_core/test_core_tohtml.py index 9f70852c..e0a0d1b3 100644 --- a/tests/test_core/test_core_tohtml.py +++ b/tests/test_core/test_core_tohtml.py @@ -42,7 +42,7 @@ def testCoreToHtml_ConvertFormat(mockGUI): theHtml._isFirst = True # Header 1 - theHtml._theText = "# Partition\n" + theHtml._text = "# Partition\n" theHtml.tokenizeText() theHtml.doConvert() assert theHtml.theResult == ( @@ -50,7 +50,7 @@ def testCoreToHtml_ConvertFormat(mockGUI): ) # Header 2 - theHtml._theText = "## Chapter Title\n" + theHtml._text = "## Chapter Title\n" theHtml.tokenizeText() theHtml.doConvert() assert theHtml.theResult == ( @@ -58,19 +58,19 @@ def testCoreToHtml_ConvertFormat(mockGUI): ) # Header 3 - theHtml._theText = "### Scene Title\n" + theHtml._text = "### Scene Title\n" theHtml.tokenizeText() theHtml.doConvert() assert theHtml.theResult == "

Scene Title

\n" # Header 4 - theHtml._theText = "#### Section Title\n" + theHtml._text = "#### Section Title\n" theHtml.tokenizeText() theHtml.doConvert() assert theHtml.theResult == "

Section Title

\n" # Title - theHtml._theText = "#! Title\n" + theHtml._text = "#! Title\n" theHtml.tokenizeText() theHtml.doConvert() assert theHtml.theResult == ( @@ -78,7 +78,7 @@ def testCoreToHtml_ConvertFormat(mockGUI): ) # Unnumbered - theHtml._theText = "##! Prologue\n" + theHtml._text = "##! Prologue\n" theHtml.tokenizeText() theHtml.doConvert() assert theHtml.theResult == "

Prologue

\n" @@ -92,31 +92,31 @@ def testCoreToHtml_ConvertFormat(mockGUI): theHtml.setLinkHeaders(True) # Header 1 - theHtml._theText = "# Heading One\n" + theHtml._text = "# Heading One\n" theHtml.tokenizeText() theHtml.doConvert() assert theHtml.theResult == "

Heading One

\n" # Header 2 - theHtml._theText = "## Heading Two\n" + theHtml._text = "## Heading Two\n" theHtml.tokenizeText() theHtml.doConvert() assert theHtml.theResult == "

Heading Two

\n" # Header 3 - theHtml._theText = "### Heading Three\n" + theHtml._text = "### Heading Three\n" theHtml.tokenizeText() theHtml.doConvert() assert theHtml.theResult == "

Heading Three

\n" # Header 4 - theHtml._theText = "#### Heading Four\n" + theHtml._text = "#### Heading Four\n" theHtml.tokenizeText() theHtml.doConvert() assert theHtml.theResult == "

Heading Four

\n" # Title - theHtml._theText = "#! Heading One\n" + theHtml._text = "#! Heading One\n" theHtml.tokenizeText() theHtml.doConvert() assert theHtml.theResult == ( @@ -124,7 +124,7 @@ def testCoreToHtml_ConvertFormat(mockGUI): ) # Unnumbered - theHtml._theText = "##! Heading Two\n" + theHtml._text = "##! Heading Two\n" theHtml.tokenizeText() theHtml.doConvert() assert theHtml.theResult == "

Heading Two

\n" @@ -133,7 +133,7 @@ def testCoreToHtml_ConvertFormat(mockGUI): # ========== # Text - theHtml._theText = "Some **nested bold and _italic_ and ~~strikethrough~~ text** here\n" + theHtml._text = "Some **nested bold and _italic_ and ~~strikethrough~~ text** here\n" theHtml.tokenizeText() theHtml.doConvert() assert theHtml.theResult == ( @@ -142,7 +142,7 @@ def testCoreToHtml_ConvertFormat(mockGUI): ) # Text w/Hard Break - theHtml._theText = "Line one \nLine two \nLine three\n" + theHtml._text = "Line one \nLine two \nLine three\n" theHtml.tokenizeText() theHtml.doConvert() assert theHtml.theResult == ( @@ -150,13 +150,13 @@ def testCoreToHtml_ConvertFormat(mockGUI): ) # Synopsis - theHtml._theText = "%synopsis: The synopsis ...\n" + theHtml._text = "%synopsis: The synopsis ...\n" theHtml.tokenizeText() theHtml.doConvert() assert theHtml.theResult == "" theHtml.setSynopsis(True) - theHtml._theText = "%synopsis: The synopsis ...\n" + theHtml._text = "%synopsis: The synopsis ...\n" theHtml.tokenizeText() theHtml.doConvert() assert theHtml.theResult == ( @@ -164,13 +164,13 @@ def testCoreToHtml_ConvertFormat(mockGUI): ) # Comment - theHtml._theText = "% A comment ...\n" + theHtml._text = "% A comment ...\n" theHtml.tokenizeText() theHtml.doConvert() assert theHtml.theResult == "" theHtml.setComments(True) - theHtml._theText = "% A comment ...\n" + theHtml._text = "% A comment ...\n" theHtml.tokenizeText() theHtml.doConvert() assert theHtml.theResult == ( @@ -178,13 +178,13 @@ def testCoreToHtml_ConvertFormat(mockGUI): ) # Keywords - theHtml._theText = "@char: Bod, Jane\n" + theHtml._text = "@char: Bod, Jane\n" theHtml.tokenizeText() theHtml.doConvert() assert theHtml.theResult == "" theHtml.setKeywords(True) - theHtml._theText = "@char: Bod, Jane\n" + theHtml._text = "@char: Bod, Jane\n" theHtml.tokenizeText() theHtml.doConvert() assert theHtml.theResult == ( @@ -194,7 +194,7 @@ def testCoreToHtml_ConvertFormat(mockGUI): # Multiple Keywords theHtml.setKeywords(True) - theHtml._theText = "## Chapter\n\n@pov: Bod\n@plot: Main\n@location: Europe\n\n" + theHtml._text = "## Chapter\n\n@pov: Bod\n@plot: Main\n@location: Europe\n\n" theHtml.tokenizeText() theHtml.doConvert() assert theHtml.theResult == ( @@ -217,7 +217,7 @@ def testCoreToHtml_ConvertFormat(mockGUI): theHtml.setPreview(True, True) # Text (HTML4) - theHtml._theText = "Some **nested bold and _italic_ and ~~strikethrough~~ text** here\n" + theHtml._text = "Some **nested bold and _italic_ and ~~strikethrough~~ text** here\n" theHtml.tokenizeText() theHtml.doConvert() assert theHtml.theResult == ( @@ -244,7 +244,7 @@ def testCoreToHtml_ConvertDirect(mockGUI): # ============== # Title - theHtml._theTokens = [ + theHtml._tokens = [ (theHtml.T_TITLE, 1, "A Title", None, theHtml.A_PBB | theHtml.A_CENTRE), (theHtml.T_EMPTY, 1, "", None, theHtml.A_NONE), ] @@ -255,7 +255,7 @@ def testCoreToHtml_ConvertDirect(mockGUI): ) # Unnumbered - theHtml._theTokens = [ + theHtml._tokens = [ (theHtml.T_UNNUM, 1, "Prologue", None, theHtml.A_PBB), (theHtml.T_EMPTY, 1, "", None, theHtml.A_NONE), ] @@ -269,7 +269,7 @@ def testCoreToHtml_ConvertDirect(mockGUI): # ========== # Separator - theHtml._theTokens = [ + theHtml._tokens = [ (theHtml.T_SEP, 1, "* * *", None, theHtml.A_CENTRE), (theHtml.T_EMPTY, 1, "", None, theHtml.A_NONE), ] @@ -277,7 +277,7 @@ def testCoreToHtml_ConvertDirect(mockGUI): assert theHtml.theResult == "

* * *

\n" # Skip - theHtml._theTokens = [ + theHtml._tokens = [ (theHtml.T_SKIP, 1, "", None, theHtml.A_NONE), (theHtml.T_EMPTY, 1, "", None, theHtml.A_NONE), ] @@ -291,7 +291,7 @@ def testCoreToHtml_ConvertDirect(mockGUI): # Align Left theHtml.setStyles(False) - theHtml._theTokens = [ + theHtml._tokens = [ (theHtml.T_HEAD1, 1, "A Title", None, theHtml.A_LEFT), ] theHtml.doConvert() @@ -302,7 +302,7 @@ def testCoreToHtml_ConvertDirect(mockGUI): theHtml.setStyles(True) # Align Left - theHtml._theTokens = [ + theHtml._tokens = [ (theHtml.T_HEAD1, 1, "A Title", None, theHtml.A_LEFT), ] theHtml.doConvert() @@ -311,7 +311,7 @@ def testCoreToHtml_ConvertDirect(mockGUI): ) # Align Right - theHtml._theTokens = [ + theHtml._tokens = [ (theHtml.T_HEAD1, 1, "A Title", None, theHtml.A_RIGHT), ] theHtml.doConvert() @@ -320,7 +320,7 @@ def testCoreToHtml_ConvertDirect(mockGUI): ) # Align Centre - theHtml._theTokens = [ + theHtml._tokens = [ (theHtml.T_HEAD1, 1, "A Title", None, theHtml.A_CENTRE), ] theHtml.doConvert() @@ -329,7 +329,7 @@ def testCoreToHtml_ConvertDirect(mockGUI): ) # Align Justify - theHtml._theTokens = [ + theHtml._tokens = [ (theHtml.T_HEAD1, 1, "A Title", None, theHtml.A_JUSTIFY), ] theHtml.doConvert() @@ -341,7 +341,7 @@ def testCoreToHtml_ConvertDirect(mockGUI): # ========== # Page Break Always - theHtml._theTokens = [ + theHtml._tokens = [ (theHtml.T_HEAD1, 1, "A Title", None, theHtml.A_PBB | theHtml.A_PBA), ] theHtml.doConvert() @@ -354,7 +354,7 @@ def testCoreToHtml_ConvertDirect(mockGUI): # ====== # Indent Left - theHtml._theTokens = [ + theHtml._tokens = [ (theHtml.T_TEXT, 1, "Some text ...", [], theHtml.A_IND_L), (theHtml.T_EMPTY, 2, "", None, theHtml.A_NONE), ] @@ -364,7 +364,7 @@ def testCoreToHtml_ConvertDirect(mockGUI): ) # Indent Right - theHtml._theTokens = [ + theHtml._tokens = [ (theHtml.T_TEXT, 1, "Some text ...", [], theHtml.A_IND_R), (theHtml.T_EMPTY, 2, "", None, theHtml.A_NONE), ] @@ -387,28 +387,28 @@ def testCoreToHtml_SpecialCases(mockGUI): # Greater/Lesser than symbols # =========================== - theHtml._theText = "Text with > and < with some **bold text** in it.\n" + theHtml._text = "Text with > and < with some **bold text** in it.\n" theHtml.tokenizeText() theHtml.doConvert() assert theHtml.theResult == ( "

Text with > and < with some bold text in it.

\n" ) - theHtml._theText = "Text with some <**bold text**> in it.\n" + theHtml._text = "Text with some <**bold text**> in it.\n" theHtml.tokenizeText() theHtml.doConvert() assert theHtml.theResult == ( "

Text with some <bold text> in it.

\n" ) - theHtml._theText = "Let's > be > _difficult **shall** > we_?\n" + theHtml._text = "Let's > be > _difficult **shall** > we_?\n" theHtml.tokenizeText() theHtml.doConvert() assert theHtml.theResult == ( "

Let's > be > difficult shall > we?

\n" ) - theHtml._theText = "Test > text _<**bold**>_ and more.\n" + theHtml._text = "Test > text _<**bold**>_ and more.\n" theHtml.tokenizeText() theHtml.doConvert() assert theHtml.theResult == ( @@ -420,7 +420,7 @@ def testCoreToHtml_SpecialCases(mockGUI): # See: https://github.com/vkbo/novelWriter/issues/950 theHtml.setComments(True) - theHtml._theText = "% Test > text _<**bold**>_ and more.\n" + theHtml._text = "% Test > text _<**bold**>_ and more.\n" theHtml.tokenizeText() theHtml.doConvert() assert theHtml.theResult == ( @@ -429,7 +429,7 @@ def testCoreToHtml_SpecialCases(mockGUI): "

\n" ) - theHtml._theText = "## Heading <1>\n" + theHtml._text = "## Heading <1>\n" theHtml.tokenizeText() theHtml.doConvert() assert theHtml.theResult == ( @@ -440,7 +440,7 @@ def testCoreToHtml_SpecialCases(mockGUI): # ==================== # See: https://github.com/vkbo/novelWriter/issues/1412 - theHtml._theText = "Test text \\**_bold_** and more.\n" + theHtml._text = "Test text \\**_bold_** and more.\n" theHtml.tokenizeText() theHtml.doConvert() assert theHtml.theResult == ( @@ -502,7 +502,7 @@ def testCoreToHtml_Complex(mockGUI, fncPath): ] for i in range(len(docText)): - theHtml._theText = docText[i] + theHtml._text = docText[i] theHtml.doPreProcessing() theHtml.tokenizeText() theHtml.doConvert() @@ -556,7 +556,7 @@ def testCoreToHtml_Methods(mockGUI): # Auto-Replace, keep Unicode docText = "Text with & short–dash, long—dash …\n" - theHtml._theText = docText + theHtml._text = docText theHtml.setReplaceUnicode(False) theHtml.doPreProcessing() theHtml.tokenizeText() @@ -567,7 +567,7 @@ def testCoreToHtml_Methods(mockGUI): # Auto-Replace, replace Unicode docText = "Text with & short–dash, long—dash …\n" - theHtml._theText = docText + theHtml._text = docText theHtml.setReplaceUnicode(True) theHtml.doPreProcessing() theHtml.tokenizeText() @@ -578,7 +578,7 @@ def testCoreToHtml_Methods(mockGUI): # With Preview theHtml.setPreview(True, True) - theHtml._theText = docText + theHtml._text = docText theHtml.doPreProcessing() theHtml.tokenizeText() theHtml.doConvert() diff --git a/tests/test_core/test_core_tokenizer.py b/tests/test_core/test_core_tokenizer.py index 39bbc8fc..7818648f 100644 --- a/tests/test_core/test_core_tokenizer.py +++ b/tests/test_core/test_core_tokenizer.py @@ -169,32 +169,32 @@ def testCoreToken_TextOps(monkeypatch, mockGUI, mockRnd, fncPath): # First Page assert theToken.addRootHeading(C.hPlotRoot) is True assert theToken.theMarkdown[-1] == "# Notes: Plot\n\n" - assert theToken._theTokens[-1] == ( + assert theToken._tokens[-1] == ( Tokenizer.T_TITLE, 0, "Notes: Plot", None, Tokenizer.A_CENTRE ) # Not First Page assert theToken.addRootHeading(C.hPlotRoot) is True assert theToken.theMarkdown[-1] == "# Notes: Plot\n\n" - assert theToken._theTokens[-1] == ( + assert theToken._tokens[-1] == ( Tokenizer.T_TITLE, 0, "Notes: Plot", None, Tokenizer.A_CENTRE | Tokenizer.A_PBB ) # Set Text assert theToken.setText("stuff") is False assert theToken.setText(C.hSceneDoc) is True - assert theToken._theText == docText + assert theToken._text == docText with monkeypatch.context() as mp: mp.setattr("novelwriter.constants.nwConst.MAX_DOCSIZE", 100) assert theToken.setText(C.hSceneDoc, docText) is True - assert theToken._theText == ( + assert theToken._text == ( "# ERROR\n\n" "Document 'New Scene' is too big (0.00 MB). Skipping.\n\n" ) assert theToken.setText(C.hSceneDoc, docText) is True - assert theToken._theText == docText + assert theToken._text == docText assert theToken._isNone is False assert theToken._isNovel is True @@ -202,7 +202,7 @@ def testCoreToken_TextOps(monkeypatch, mockGUI, mockRnd, fncPath): # Pre Processing theToken.doPreProcessing() - assert theToken._theText == docTextR + assert theToken._text == docTextR # Save File savePath = fncPath / "dump.nwd" @@ -246,10 +246,10 @@ def testCoreToken_HeaderFormat(mockGUI): theToken._isNovel = True theToken._isNote = False theToken._isFirst = True - theToken._theText = "#! Novel Title\n" + theToken._text = "#! Novel Title\n" theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_TITLE, 1, "Novel Title", None, Tokenizer.A_CENTRE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] @@ -259,10 +259,10 @@ def testCoreToken_HeaderFormat(mockGUI): theToken._isNovel = False theToken._isNote = True theToken._isFirst = True - theToken._theText = "#! Note Title\n" + theToken._text = "#! Note Title\n" theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_HEAD1, 1, "Note Title", None, Tokenizer.A_CENTRE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] @@ -275,10 +275,10 @@ def testCoreToken_HeaderFormat(mockGUI): theToken._isNovel = True theToken._isNote = False theToken._isFirst = True - theToken._theText = "# Novel Title\n" + theToken._text = "# Novel Title\n" theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_HEAD1, 1, "Novel Title", None, Tokenizer.A_CENTRE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] @@ -288,10 +288,10 @@ def testCoreToken_HeaderFormat(mockGUI): theToken._isNovel = False theToken._isNote = True theToken._isFirst = True - theToken._theText = "# Note Title\n" + theToken._text = "# Note Title\n" theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_HEAD1, 1, "Note Title", None, Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] @@ -303,10 +303,10 @@ def testCoreToken_HeaderFormat(mockGUI): # Story File theToken._isNovel = True theToken._isNote = False - theToken._theText = "## Chapter One\n" + theToken._text = "## Chapter One\n" theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_HEAD2, 1, "Chapter One", None, Tokenizer.A_PBB), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] @@ -315,10 +315,10 @@ def testCoreToken_HeaderFormat(mockGUI): # Note File theToken._isNovel = False theToken._isNote = True - theToken._theText = "## Heading 2\n" + theToken._text = "## Heading 2\n" theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_HEAD2, 1, "Heading 2", None, Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] @@ -330,10 +330,10 @@ def testCoreToken_HeaderFormat(mockGUI): # Story File theToken._isNovel = True theToken._isNote = False - theToken._theText = "### Scene One\n" + theToken._text = "### Scene One\n" theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_HEAD3, 1, "Scene One", None, Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] @@ -342,10 +342,10 @@ def testCoreToken_HeaderFormat(mockGUI): # Note File theToken._isNovel = False theToken._isNote = True - theToken._theText = "### Heading 3\n" + theToken._text = "### Heading 3\n" theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_HEAD3, 1, "Heading 3", None, Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] @@ -357,10 +357,10 @@ def testCoreToken_HeaderFormat(mockGUI): # Story File theToken._isNovel = True theToken._isNote = False - theToken._theText = "#### A Section\n" + theToken._text = "#### A Section\n" theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_HEAD4, 1, "A Section", None, Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] @@ -369,10 +369,10 @@ def testCoreToken_HeaderFormat(mockGUI): # Note File theToken._isNovel = False theToken._isNote = True - theToken._theText = "#### Heading 4\n" + theToken._text = "#### Heading 4\n" theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_HEAD4, 1, "Heading 4", None, Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] @@ -384,10 +384,10 @@ def testCoreToken_HeaderFormat(mockGUI): # Story File theToken._isNovel = True theToken._isNote = False - theToken._theText = "#! Title\n" + theToken._text = "#! Title\n" theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_TITLE, 1, "Title", None, Tokenizer.A_CENTRE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] @@ -396,10 +396,10 @@ def testCoreToken_HeaderFormat(mockGUI): # Note File theToken._isNovel = False theToken._isNote = True - theToken._theText = "#! Title\n" + theToken._text = "#! Title\n" theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_HEAD1, 1, "Title", None, Tokenizer.A_CENTRE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] @@ -411,10 +411,10 @@ def testCoreToken_HeaderFormat(mockGUI): # Story File theToken._isNovel = True theToken._isNote = False - theToken._theText = "##! Prologue\n" + theToken._text = "##! Prologue\n" theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_UNNUM, 1, "Prologue", None, Tokenizer.A_PBB), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] @@ -423,10 +423,10 @@ def testCoreToken_HeaderFormat(mockGUI): # Note File theToken._isNovel = False theToken._isNote = True - theToken._theText = "##! Prologue\n" + theToken._text = "##! Prologue\n" theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_HEAD2, 1, "Prologue", None, Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] @@ -444,9 +444,9 @@ def testCoreToken_MetaFormat(mockGUI): theToken.setKeepMarkdown(True) # Comment - theToken._theText = "% A comment\n" + theToken._text = "% A comment\n" theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_COMMENT, 1, "A comment", None, Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] @@ -457,15 +457,15 @@ def testCoreToken_MetaFormat(mockGUI): assert theToken.theMarkdown[-1] == "% A comment\n\n" # Symopsis - theToken._theText = "%synopsis: The synopsis\n" + theToken._text = "%synopsis: The synopsis\n" theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_SYNOPSIS, 1, "The synopsis", None, Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] - theToken._theText = "% synopsis: The synopsis\n" + theToken._text = "% synopsis: The synopsis\n" theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_SYNOPSIS, 1, "The synopsis", None, Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] @@ -476,9 +476,9 @@ def testCoreToken_MetaFormat(mockGUI): assert theToken.theMarkdown[-1] == "% synopsis: The synopsis\n\n" # Keyword - theToken._theText = "@char: Bod\n" + theToken._text = "@char: Bod\n" theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_KEYWORD, 1, "char: Bod", None, Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] @@ -488,12 +488,12 @@ def testCoreToken_MetaFormat(mockGUI): theToken.tokenizeText() assert theToken.theMarkdown[-1] == "@char: Bod\n\n" - theToken._theText = "@pov: Bod\n@plot: Main\n@location: Europe\n" + theToken._text = "@pov: Bod\n@plot: Main\n@location: Europe\n" theToken.tokenizeText() styTop = Tokenizer.A_NONE | Tokenizer.A_Z_BTMMRG styMid = Tokenizer.A_NONE | Tokenizer.A_Z_BTMMRG | Tokenizer.A_Z_TOPMRG styBtm = Tokenizer.A_NONE | Tokenizer.A_Z_TOPMRG - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_KEYWORD, 1, "pov: Bod", None, styTop), (Tokenizer.T_KEYWORD, 2, "plot: Main", None, styMid), (Tokenizer.T_KEYWORD, 3, "location: Europe", None, styBtm), @@ -515,7 +515,7 @@ def testCoreToken_MarginFormat(mockGUI): # Alignment and Indentation dblIndent = Tokenizer.A_IND_L | Tokenizer.A_IND_R rIndAlign = Tokenizer.A_RIGHT | Tokenizer.A_IND_R - theToken._theText = ( + theToken._text = ( "Some regular text\n\n" "Some left-aligned text <<\n\n" ">> Some right-aligned text\n\n" @@ -526,7 +526,7 @@ def testCoreToken_MarginFormat(mockGUI): ">> Right-indent, right-aligned <\n\n" ) theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_TEXT, 1, "Some regular text", [], Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 2, "", None, Tokenizer.A_NONE), (Tokenizer.T_TEXT, 3, "Some left-aligned text", [], Tokenizer.A_LEFT), @@ -568,9 +568,9 @@ def testCoreToken_TextFormat(mockGUI): theToken.setKeepMarkdown(True) # Text - theToken._theText = "Some plain text\non two lines\n\n\n" + theToken._text = "Some plain text\non two lines\n\n\n" theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_TEXT, 1, "Some plain text", [], Tokenizer.A_NONE), (Tokenizer.T_TEXT, 2, "on two lines", [], Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 3, "", None, Tokenizer.A_NONE), @@ -581,7 +581,7 @@ def testCoreToken_TextFormat(mockGUI): theToken.setBodyText(False) theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_EMPTY, 3, "", None, Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 4, "", None, Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 4, "", None, Tokenizer.A_NONE), @@ -590,9 +590,9 @@ def testCoreToken_TextFormat(mockGUI): theToken.setBodyText(True) # Text Emphasis - theToken._theText = "Some **bolded text** on this lines\n" + theToken._text = "Some **bolded text** on this lines\n" theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ ( Tokenizer.T_TEXT, 1, "Some **bolded text** on this lines", @@ -606,9 +606,9 @@ def testCoreToken_TextFormat(mockGUI): ] assert theToken.theMarkdown[-1] == "Some **bolded text** on this lines\n\n" - theToken._theText = "Some _italic text_ on this lines\n" + theToken._text = "Some _italic text_ on this lines\n" theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ ( Tokenizer.T_TEXT, 1, "Some _italic text_ on this lines", @@ -622,9 +622,9 @@ def testCoreToken_TextFormat(mockGUI): ] assert theToken.theMarkdown[-1] == "Some _italic text_ on this lines\n\n" - theToken._theText = "Some **_bold italic text_** on this lines\n" + theToken._text = "Some **_bold italic text_** on this lines\n" theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ ( Tokenizer.T_TEXT, 1, "Some **_bold italic text_** on this lines", @@ -640,9 +640,9 @@ def testCoreToken_TextFormat(mockGUI): ] assert theToken.theMarkdown[-1] == "Some **_bold italic text_** on this lines\n\n" - theToken._theText = "Some ~~strikethrough text~~ on this lines\n" + theToken._text = "Some ~~strikethrough text~~ on this lines\n" theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ ( Tokenizer.T_TEXT, 1, "Some ~~strikethrough text~~ on this lines", @@ -656,9 +656,9 @@ def testCoreToken_TextFormat(mockGUI): ] assert theToken.theMarkdown[-1] == "Some ~~strikethrough text~~ on this lines\n\n" - theToken._theText = "Some **nested bold and _italic_ and ~~strikethrough~~ text** here\n" + theToken._text = "Some **nested bold and _italic_ and ~~strikethrough~~ text** here\n" theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ ( Tokenizer.T_TEXT, 1, "Some **nested bold and _italic_ and ~~strikethrough~~ text** here", @@ -704,44 +704,44 @@ def testCoreToken_SpecialFormat(mockGUI): # Command wo/Space theToken._isFirst = True - theToken._theText = ( + theToken._text = ( "# Title One\n\n" "[NEWPAGE]\n\n" "# Title Two\n\n" ) theToken.tokenizeText() - assert theToken._theTokens == correctResp + assert theToken._tokens == correctResp # Command w/Space theToken._isFirst = True - theToken._theText = ( + theToken._text = ( "# Title One\n\n" "[NEW PAGE]\n\n" "# Title Two\n\n" ) theToken.tokenizeText() - assert theToken._theTokens == correctResp + assert theToken._tokens == correctResp # Trailing Spaces theToken._isFirst = True - theToken._theText = ( + theToken._text = ( "# Title One\n\n" "[NEW PAGE] \t\n\n" "# Title Two\n\n" ) theToken.tokenizeText() - assert theToken._theTokens == correctResp + assert theToken._tokens == correctResp # Single Empty Paragraph # ====================== - theToken._theText = ( + theToken._text = ( "# Title One\n\n" "[VSPACE] \n\n" "Some text to go here ...\n\n" ) theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_HEAD1, 1, "Title One", None, Tokenizer.A_PBB | Tokenizer.A_CENTRE), (Tokenizer.T_EMPTY, 2, "", None, Tokenizer.A_NONE), (Tokenizer.T_SKIP, 3, "", None, Tokenizer.A_NONE), @@ -755,13 +755,13 @@ def testCoreToken_SpecialFormat(mockGUI): # ========================= # One Skip - theToken._theText = ( + theToken._text = ( "# Title One\n\n" "[VSPACE:1] \n\n" "Some text to go here ...\n\n" ) theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_HEAD1, 1, "Title One", None, Tokenizer.A_PBB | Tokenizer.A_CENTRE), (Tokenizer.T_EMPTY, 2, "", None, Tokenizer.A_NONE), (Tokenizer.T_SKIP, 3, "", None, Tokenizer.A_NONE), @@ -772,13 +772,13 @@ def testCoreToken_SpecialFormat(mockGUI): ] # Three Skips - theToken._theText = ( + theToken._text = ( "# Title One\n\n" "[VSPACE:3] \n\n" "Some text to go here ...\n\n" ) theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_HEAD1, 1, "Title One", None, Tokenizer.A_PBB | Tokenizer.A_CENTRE), (Tokenizer.T_EMPTY, 2, "", None, Tokenizer.A_NONE), (Tokenizer.T_SKIP, 3, "", None, Tokenizer.A_NONE), @@ -791,13 +791,13 @@ def testCoreToken_SpecialFormat(mockGUI): ] # Malformed Command, Case 1 - theToken._theText = ( + theToken._text = ( "# Title One\n\n" "[VSPACE:3xa] \n\n" "Some text to go here ...\n\n" ) theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_HEAD1, 1, "Title One", None, Tokenizer.A_PBB | Tokenizer.A_CENTRE), (Tokenizer.T_EMPTY, 2, "", None, Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 4, "", None, Tokenizer.A_NONE), @@ -807,13 +807,13 @@ def testCoreToken_SpecialFormat(mockGUI): ] # Malformed Command, Case 2 - theToken._theText = ( + theToken._text = ( "# Title One\n\n" "[VSPACE:3.5]\n\n" "Some text to go here ...\n\n" ) theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_HEAD1, 1, "Title One", None, Tokenizer.A_PBB | Tokenizer.A_CENTRE), (Tokenizer.T_EMPTY, 2, "", None, Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 4, "", None, Tokenizer.A_NONE), @@ -823,13 +823,13 @@ def testCoreToken_SpecialFormat(mockGUI): ] # Malformed Command, Case 3 - theToken._theText = ( + theToken._text = ( "# Title One\n\n" "[VSPACE:-1]\n\n" "Some text to go here ...\n\n" ) theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_HEAD1, 1, "Title One", None, Tokenizer.A_PBB | Tokenizer.A_CENTRE), (Tokenizer.T_EMPTY, 2, "", None, Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 4, "", None, Tokenizer.A_NONE), @@ -842,14 +842,14 @@ def testCoreToken_SpecialFormat(mockGUI): # ============================== # Single Skip - theToken._theText = ( + theToken._text = ( "# Title One\n\n" "[NEW PAGE]\n\n" "[VSPACE]\n\n" "Some text to go here ...\n\n" ) theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_HEAD1, 1, "Title One", None, Tokenizer.A_PBB | Tokenizer.A_CENTRE), (Tokenizer.T_EMPTY, 2, "", None, Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 4, "", None, Tokenizer.A_NONE), @@ -861,14 +861,14 @@ def testCoreToken_SpecialFormat(mockGUI): ] # Multiple Skip - theToken._theText = ( + theToken._text = ( "# Title One\n\n" "[NEW PAGE]\n\n" "[VSPACE:3]\n\n" "Some text to go here ...\n\n" ) theToken.tokenizeText() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_HEAD1, 1, "Title One", None, Tokenizer.A_PBB | Tokenizer.A_CENTRE), (Tokenizer.T_EMPTY, 2, "", None, Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 4, "", None, Tokenizer.A_NONE), @@ -894,7 +894,7 @@ def testCoreToken_ProcessHeaders(mockGUI): theToken = BareTokenizer(theProject) # Nothing - theToken._theText = "Some text ...\n" + theToken._text = "Some text ...\n" assert theToken.doHeaders() is False theToken._isNone = True assert theToken.doHeaders() is False @@ -917,22 +917,22 @@ def testCoreToken_ProcessHeaders(mockGUI): # H1: Title, First Page assert theToken._isFirst is True - theToken._theText = "# Part One\n" + theToken._text = "# Part One\n" theToken.setTitleFormat(f"T: {nwHeadFmt.TITLE}") theToken.tokenizeText() theToken.doHeaders() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_HEAD1, 1, "T: Part One", None, Tokenizer.A_CENTRE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] # H1: Title, Not First Page assert theToken._isFirst is False - theToken._theText = "# Part One\n" + theToken._text = "# Part One\n" theToken.setTitleFormat(f"T: {nwHeadFmt.TITLE}") theToken.tokenizeText() theToken.doHeaders() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_HEAD1, 1, "T: Part One", None, Tokenizer.A_PBB | Tokenizer.A_CENTRE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] @@ -941,52 +941,52 @@ def testCoreToken_ProcessHeaders(mockGUI): # ======== # H2: Chapter - theToken._theText = "## Chapter One\n" + theToken._text = "## Chapter One\n" theToken.setChapterFormat(f"C: {nwHeadFmt.TITLE}") theToken.tokenizeText() theToken.doHeaders() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_HEAD2, 1, "C: Chapter One", None, Tokenizer.A_PBB), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] # H2: Unnumbered Chapter - theToken._theText = "##! Prologue\n" + theToken._text = "##! Prologue\n" theToken.setUnNumberedFormat(f"U: {nwHeadFmt.TITLE}") theToken.tokenizeText() theToken.doHeaders() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_UNNUM, 1, "U: Prologue", None, Tokenizer.A_PBB), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] # H2: Chapter Word Number - theToken._theText = "## Chapter\n" + theToken._text = "## Chapter\n" theToken.setChapterFormat(f"Chapter {nwHeadFmt.CH_WORD}") theToken._hFormatter._chCount = 0 theToken.tokenizeText() theToken.doHeaders() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_HEAD2, 1, "Chapter One", None, Tokenizer.A_PBB), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] # H2: Chapter Roman Number Upper Case - theToken._theText = "## Chapter\n" + theToken._text = "## Chapter\n" theToken.setChapterFormat(f"Chapter {nwHeadFmt.CH_ROMU}") theToken.tokenizeText() theToken.doHeaders() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_HEAD2, 1, "Chapter II", None, Tokenizer.A_PBB), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] # H2: Chapter Roman Number Lower Case - theToken._theText = "## Chapter\n" + theToken._text = "## Chapter\n" theToken.setChapterFormat(f"Chapter {nwHeadFmt.CH_ROML}") theToken.tokenizeText() theToken.doHeaders() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_HEAD2, 1, "Chapter iii", None, Tokenizer.A_PBB), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] @@ -995,89 +995,89 @@ def testCoreToken_ProcessHeaders(mockGUI): # ====== # H3: Scene w/Title - theToken._theText = "### Scene One\n" + theToken._text = "### Scene One\n" theToken.setSceneFormat(f"S: {nwHeadFmt.TITLE}", False) theToken.tokenizeText() theToken.doHeaders() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_HEAD3, 1, "S: Scene One", None, Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] # H3: Scene Hidden wo/Format - theToken._theText = "### Scene One\n" + theToken._text = "### Scene One\n" theToken.setSceneFormat("", True) theToken.tokenizeText() theToken.doHeaders() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] # H3: Scene wo/Format, first - theToken._theText = "### Scene One\n" + theToken._text = "### Scene One\n" theToken.setSceneFormat("", False) theToken._firstScene = True theToken.tokenizeText() theToken.doHeaders() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] # H3: Scene wo/Format, not first - theToken._theText = "### Scene One\n" + theToken._text = "### Scene One\n" theToken.setSceneFormat("", False) theToken._firstScene = False theToken.tokenizeText() theToken.doHeaders() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_SKIP, 1, "", None, Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] # H3: Scene Separator, first - theToken._theText = "### Scene One\n" + theToken._text = "### Scene One\n" theToken.setSceneFormat("* * *", False) theToken._firstScene = True theToken.tokenizeText() theToken.doHeaders() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] # H3: Scene Separator, not first - theToken._theText = "### Scene One\n" + theToken._text = "### Scene One\n" theToken.setSceneFormat("* * *", False) theToken._firstScene = False theToken.tokenizeText() theToken.doHeaders() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_SEP, 1, "* * *", None, Tokenizer.A_CENTRE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] # H3: Scene w/Absolute Number - theToken._theText = "### A Scene\n" + theToken._text = "### A Scene\n" theToken.setSceneFormat(f"Scene {nwHeadFmt.SC_ABS}", False) theToken._hFormatter._scAbsCount = 0 theToken._hFormatter._scChCount = 0 theToken.tokenizeText() theToken.doHeaders() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_HEAD3, 1, "Scene 1", None, Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] # H3: Scene w/Chapter Number - theToken._theText = "### A Scene\n" + theToken._text = "### A Scene\n" theToken.setSceneFormat(f"Scene {nwHeadFmt.CH_NUM}.{nwHeadFmt.SC_NUM}", False) theToken._hFormatter._scAbsCount = 0 theToken._hFormatter._scChCount = 1 theToken.tokenizeText() theToken.doHeaders() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_HEAD3, 1, "Scene 3.2", None, Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] @@ -1086,41 +1086,41 @@ def testCoreToken_ProcessHeaders(mockGUI): # ======== # H4: Section Hidden wo/Format - theToken._theText = "#### A Section\n" + theToken._text = "#### A Section\n" theToken.setSectionFormat(r"", True) theToken.tokenizeText() theToken.doHeaders() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] # H4: Section Visible wo/Format - theToken._theText = "#### A Section\n" + theToken._text = "#### A Section\n" theToken.setSectionFormat("", False) theToken.tokenizeText() theToken.doHeaders() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_SKIP, 1, "", None, Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] # H4: Section w/Format - theToken._theText = "#### A Section\n" + theToken._text = "#### A Section\n" theToken.setSectionFormat(f"X: {nwHeadFmt.TITLE}", False) theToken.tokenizeText() theToken.doHeaders() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_HEAD4, 1, "X: A Section", None, Tokenizer.A_NONE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] # H4: Section Separator - theToken._theText = "#### A Section\n" + theToken._text = "#### A Section\n" theToken.setSectionFormat("* * *", False) theToken.tokenizeText() theToken.doHeaders() - assert theToken._theTokens == [ + assert theToken._tokens == [ (Tokenizer.T_SEP, 1, "* * *", None, Tokenizer.A_CENTRE), (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), ] @@ -1128,7 +1128,7 @@ def testCoreToken_ProcessHeaders(mockGUI): # Check the first scene detector assert theToken._firstScene is False theToken._firstScene = True - theToken._theText = "Some text ...\n" + theToken._text = "Some text ...\n" theToken.tokenizeText() theToken.doHeaders() assert theToken._firstScene is False diff --git a/tests/test_core/test_core_tomd.py b/tests/test_core/test_core_tomd.py index 43f44d35..e4df4bbb 100644 --- a/tests/test_core/test_core_tomd.py +++ b/tests/test_core/test_core_tomd.py @@ -42,37 +42,37 @@ def testCoreToMarkdown_ConvertFormat(mockGUI): theMD._isFirst = True # Header 1 - theMD._theText = "# Partition\n" + theMD._text = "# Partition\n" theMD.tokenizeText() theMD.doConvert() assert theMD.theResult == "# Partition\n\n" # Header 2 - theMD._theText = "## Chapter Title\n" + theMD._text = "## Chapter Title\n" theMD.tokenizeText() theMD.doConvert() assert theMD.theResult == "## Chapter Title\n\n" # Header 3 - theMD._theText = "### Scene Title\n" + theMD._text = "### Scene Title\n" theMD.tokenizeText() theMD.doConvert() assert theMD.theResult == "### Scene Title\n\n" # Header 4 - theMD._theText = "#### Section Title\n" + theMD._text = "#### Section Title\n" theMD.tokenizeText() theMD.doConvert() assert theMD.theResult == "#### Section Title\n\n" # Title - theMD._theText = "#! Title\n" + theMD._text = "#! Title\n" theMD.tokenizeText() theMD.doConvert() assert theMD.theResult == "# Title\n\n" # Unnumbered - theMD._theText = "##! Prologue\n" + theMD._text = "##! Prologue\n" theMD.tokenizeText() theMD.doConvert() assert theMD.theResult == "## Prologue\n\n" @@ -82,7 +82,7 @@ def testCoreToMarkdown_ConvertFormat(mockGUI): # Text for GitHub Markdown theMD.setGitHubMarkdown() - theMD._theText = "Some **nested bold and _italic_ and ~~strikethrough~~ text** here\n" + theMD._text = "Some **nested bold and _italic_ and ~~strikethrough~~ text** here\n" theMD.tokenizeText() theMD.doConvert() assert theMD.theResult == ( @@ -91,7 +91,7 @@ def testCoreToMarkdown_ConvertFormat(mockGUI): # Text for Standard Markdown theMD.setStandardMarkdown() - theMD._theText = "Some **nested bold and _italic_ and ~~strikethrough~~ text** here\n" + theMD._text = "Some **nested bold and _italic_ and ~~strikethrough~~ text** here\n" theMD.tokenizeText() theMD.doConvert() assert theMD.theResult == ( @@ -99,50 +99,50 @@ def testCoreToMarkdown_ConvertFormat(mockGUI): ) # Text w/Hard Break - theMD._theText = "Line one \nLine two \nLine three\n" + theMD._text = "Line one \nLine two \nLine three\n" theMD.tokenizeText() theMD.doConvert() assert theMD.theResult == "Line one \nLine two \nLine three\n\n" # Synopsis - theMD._theText = "%synopsis: The synopsis ...\n" + theMD._text = "%synopsis: The synopsis ...\n" theMD.tokenizeText() theMD.doConvert() assert theMD.theResult == "" theMD.setSynopsis(True) - theMD._theText = "%synopsis: The synopsis ...\n" + theMD._text = "%synopsis: The synopsis ...\n" theMD.tokenizeText() theMD.doConvert() assert theMD.theResult == "**Synopsis:** The synopsis ...\n\n" # Comment - theMD._theText = "% A comment ...\n" + theMD._text = "% A comment ...\n" theMD.tokenizeText() theMD.doConvert() assert theMD.theResult == "" theMD.setComments(True) - theMD._theText = "% A comment ...\n" + theMD._text = "% A comment ...\n" theMD.tokenizeText() theMD.doConvert() assert theMD.theResult == "**Comment:** A comment ...\n\n" # Keywords - theMD._theText = "@char: Bod, Jane\n" + theMD._text = "@char: Bod, Jane\n" theMD.tokenizeText() theMD.doConvert() assert theMD.theResult == "" theMD.setKeywords(True) - theMD._theText = "@char: Bod, Jane\n" + theMD._text = "@char: Bod, Jane\n" theMD.tokenizeText() theMD.doConvert() assert theMD.theResult == "**Characters:** Bod, Jane\n\n" # Multiple Keywords theMD.setKeywords(True) - theMD._theText = "## Chapter\n\n@pov: Bod\n@plot: Main\n@location: Europe\n\n" + theMD._text = "## Chapter\n\n@pov: Bod\n@plot: Main\n@location: Europe\n\n" theMD.tokenizeText() theMD.doConvert() assert theMD.theResult == ( @@ -169,7 +169,7 @@ def testCoreToMarkdown_ConvertDirect(mockGUI): # ============== # Title - theMD._theTokens = [ + theMD._tokens = [ (theMD.T_TITLE, 1, "A Title", None, theMD.A_PBB | theMD.A_CENTRE), (theMD.T_EMPTY, 1, "", None, theMD.A_NONE), ] @@ -177,7 +177,7 @@ def testCoreToMarkdown_ConvertDirect(mockGUI): assert theMD.theResult == "# A Title\n\n" # Unnumbered - theMD._theTokens = [ + theMD._tokens = [ (theMD.T_UNNUM, 1, "Prologue", None, theMD.A_PBB), (theMD.T_EMPTY, 1, "", None, theMD.A_NONE), ] @@ -188,7 +188,7 @@ def testCoreToMarkdown_ConvertDirect(mockGUI): # ========== # Separator - theMD._theTokens = [ + theMD._tokens = [ (theMD.T_SEP, 1, "* * *", None, theMD.A_CENTRE), (theMD.T_EMPTY, 1, "", None, theMD.A_NONE), ] @@ -196,7 +196,7 @@ def testCoreToMarkdown_ConvertDirect(mockGUI): assert theMD.theResult == "* * *\n\n" # Skip - theMD._theTokens = [ + theMD._tokens = [ (theMD.T_SKIP, 1, "", None, theMD.A_NONE), (theMD.T_EMPTY, 1, "", None, theMD.A_NONE), ] @@ -237,7 +237,7 @@ def testCoreToMarkdown_Complex(mockGUI, fncPath): ] for i in range(len(docText)): - theMD._theText = docText[i] + theMD._text = docText[i] theMD.doPreProcessing() theMD.tokenizeText() theMD.doConvert() diff --git a/tests/test_core/test_core_toodt.py b/tests/test_core/test_core_toodt.py index 55c6d785..015e97b8 100644 --- a/tests/test_core/test_core_toodt.py +++ b/tests/test_core/test_core_toodt.py @@ -261,7 +261,7 @@ def testCoreToOdt_Convert(mockGUI): # ======= # Header 1 - theDoc._theText = "# Title\n" + theDoc._text = "# Title\n" theDoc.tokenizeText() theDoc.initDocument() theDoc.doConvert() @@ -274,7 +274,7 @@ def testCoreToOdt_Convert(mockGUI): ) # Header 2 - theDoc._theText = "## Chapter\n" + theDoc._text = "## Chapter\n" theDoc.tokenizeText() theDoc.initDocument() theDoc.doConvert() @@ -287,7 +287,7 @@ def testCoreToOdt_Convert(mockGUI): ) # Header 3 - theDoc._theText = "### Scene\n" + theDoc._text = "### Scene\n" theDoc.tokenizeText() theDoc.initDocument() theDoc.doConvert() @@ -300,7 +300,7 @@ def testCoreToOdt_Convert(mockGUI): ) # Header 4 - theDoc._theText = "#### Section\n" + theDoc._text = "#### Section\n" theDoc.tokenizeText() theDoc.initDocument() theDoc.doConvert() @@ -313,7 +313,7 @@ def testCoreToOdt_Convert(mockGUI): ) # Title - theDoc._theText = "#! Title\n" + theDoc._text = "#! Title\n" theDoc.tokenizeText() theDoc.initDocument() theDoc.doConvert() @@ -326,7 +326,7 @@ def testCoreToOdt_Convert(mockGUI): ) # Unnumbered chapter - theDoc._theText = "##! Prologue\n" + theDoc._text = "##! Prologue\n" theDoc.tokenizeText() theDoc.initDocument() theDoc.doConvert() @@ -342,7 +342,7 @@ def testCoreToOdt_Convert(mockGUI): # ========== # Nested Text - theDoc._theText = "Some ~~nested **bold** and _italics_ text~~ text." + theDoc._text = "Some ~~nested **bold** and _italics_ text~~ text." theDoc.tokenizeText() theDoc.initDocument() theDoc.doConvert() @@ -360,7 +360,7 @@ def testCoreToOdt_Convert(mockGUI): ) # Hard Break - theDoc._theText = "Some text.\nNext line\n" + theDoc._text = "Some text.\nNext line\n" theDoc.tokenizeText() theDoc.initDocument() theDoc.doConvert() @@ -373,7 +373,7 @@ def testCoreToOdt_Convert(mockGUI): ) # Tab - theDoc._theText = "\tItem 1\tItem 2\n" + theDoc._text = "\tItem 1\tItem 2\n" theDoc.tokenizeText() theDoc.initDocument() theDoc.doConvert() @@ -386,7 +386,7 @@ def testCoreToOdt_Convert(mockGUI): ) # Tab in Format - theDoc._theText = "Some **bold\ttext**" + theDoc._text = "Some **bold\ttext**" theDoc.tokenizeText() theDoc.initDocument() theDoc.doConvert() @@ -400,7 +400,7 @@ def testCoreToOdt_Convert(mockGUI): ) # Multiple Spaces - theDoc._theText = ( + theDoc._text = ( "### Scene\n\n" "Hello World\n\n" "Hello World\n\n" @@ -421,7 +421,7 @@ def testCoreToOdt_Convert(mockGUI): ) # Synopsis, Comment, Keywords - theDoc._theText = ( + theDoc._text = ( "### Scene\n\n" "@pov: Jane\n\n" "% synopsis: So it begins\n\n" @@ -448,7 +448,7 @@ def testCoreToOdt_Convert(mockGUI): ) # Scene Separator - theDoc._theText = "### Scene One\n\nText\n\n### Scene Two\n\nText" + theDoc._text = "### Scene One\n\nText\n\n### Scene Two\n\nText" theDoc.setSceneFormat("* * *", False) theDoc.tokenizeText() theDoc.doHeaders() @@ -466,7 +466,7 @@ def testCoreToOdt_Convert(mockGUI): ) # Scene Break - theDoc._theText = "### Scene One\n\nText\n\n### Scene Two\n\nText" + theDoc._text = "### Scene One\n\nText\n\n### Scene Two\n\nText" theDoc.setSceneFormat("", False) theDoc.tokenizeText() theDoc.doHeaders() @@ -484,7 +484,7 @@ def testCoreToOdt_Convert(mockGUI): ) # Paragraph Styles - theDoc._theText = ( + theDoc._text = ( "### Scene\n\n" "@pov: Jane\n" "@char: John\n" @@ -526,7 +526,7 @@ def testCoreToOdt_Convert(mockGUI): assert getStyle("P8")._pAttr["margin-right"] == ["fo", "1.693cm"] # Justified - theDoc._theText = ( + theDoc._text = ( "### Scene\n\n" "Regular paragraph\n\n" "with\nbreak\n\n" @@ -549,7 +549,7 @@ def testCoreToOdt_Convert(mockGUI): assert getStyle("P9")._pAttr["text-align"] == ["fo", "left"] # Page Breaks - theDoc._theText = ( + theDoc._text = ( "## Chapter One\n\n" "Text\n\n" "## Chapter Two\n\n" @@ -584,7 +584,7 @@ def testCoreToOdt_ConvertDirect(mockGUI): # Justified theDoc = ToOdt(theProject, isFlat=True) - theDoc._theTokens = [ + theDoc._tokens = [ (theDoc.T_TEXT, 1, "This is a paragraph", [], theDoc.A_JUSTIFY), (theDoc.T_EMPTY, 1, "", None, theDoc.A_NONE), ] @@ -605,7 +605,7 @@ def testCoreToOdt_ConvertDirect(mockGUI): # Page Break After theDoc = ToOdt(theProject, isFlat=True) - theDoc._theTokens = [ + theDoc._tokens = [ (theDoc.T_TEXT, 1, "This is a paragraph", [], theDoc.A_PBA), (theDoc.T_EMPTY, 1, "", None, theDoc.A_NONE), ] @@ -643,7 +643,7 @@ def testCoreToOdt_SaveFlat(mockGUI, fncPath, tstPaths): assert theDoc.setLanguage("nb_NO") is True theDoc.setColourHeaders(True) - theDoc._theText = ( + theDoc._text = ( "## Chapter One\n\n" "Text\n\n" "## Chapter Two\n\n" @@ -680,7 +680,7 @@ def testCoreToOdt_SaveFull(mockGUI, fncPath, tstPaths): theDoc = ToOdt(theProject, isFlat=False) theDoc._isNovel = True - theDoc._theText = ( + theDoc._text = ( "## Chapter One\n\n" "Text\n\n" "## Chapter Two\n\n" From 6b6c517b1ff0d68cbb4c19bd6354767cc74440bd Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 6 Jun 2023 00:35:57 +0200 Subject: [PATCH 65/74] Add annotations to tokenizer, html and markdown classes --- novelwriter/core/tohtml.py | 123 +++++++++++++-------------- novelwriter/core/tokenizer.py | 153 +++++++++++++++++++--------------- novelwriter/core/tomd.py | 67 +++++++-------- 3 files changed, 177 insertions(+), 166 deletions(-) diff --git a/novelwriter/core/tohtml.py b/novelwriter/core/tohtml.py index 51a9ef44..323b617c 100644 --- a/novelwriter/core/tohtml.py +++ b/novelwriter/core/tohtml.py @@ -22,11 +22,15 @@ 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 . """ +from __future__ import annotations import logging +from pathlib import Path + from novelwriter import CONFIG from novelwriter.constants import nwKeyWords, nwLabels, nwHtmlUnicode +from novelwriter.core.project import NWProject from novelwriter.core.tokenizer import Tokenizer, stripEscape logger = logging.getLogger(__name__) @@ -38,8 +42,8 @@ class ToHtml(Tokenizer): M_EXPORT = 1 # Tweak output for saving to HTML or printing M_EBOOK = 2 # Tweak output for converting to epub - def __init__(self, theProject): - super().__init__(theProject) + def __init__(self, project: NWProject): + super().__init__(project) self._genMode = self.M_EXPORT self._cssStyles = True @@ -63,7 +67,7 @@ class ToHtml(Tokenizer): # Setters ## - def setPreview(self, doComments, doSynopsis): + def setPreview(self, doComments: bool, doSynopsis: bool): """If we're using this class to generate markdown preview, we need to make a few changes to formatting, which is managed by these flags. @@ -74,14 +78,14 @@ class ToHtml(Tokenizer): self._doSynopsis = doSynopsis return - def setStyles(self, cssStyles): - """Enable/disable CSS styling. Some elements may still have + def setStyles(self, cssStyles: bool): + """Enable or disable CSS styling. Some elements may still have class tags. """ self._cssStyles = cssStyles return - def setReplaceUnicode(self, doReplace): + def setReplaceUnicode(self, doReplace: bool): """Set the translation map to either minimal or full unicode for html entities replacement. """ @@ -92,16 +96,14 @@ class ToHtml(Tokenizer): if doReplace: # Extend to all relevant Unicode characters self._trMap.update(str.maketrans(nwHtmlUnicode.U_TO_H)) - return ## # Class Methods ## - def getFullResultSize(self): - """Return the size of the full HTML result. - """ + def getFullResultSize(self) -> int: + """Return the size of the full HTML result.""" return sum([len(x) for x in self._fullHTML]) def doPreProcessing(self): @@ -114,7 +116,7 @@ class ToHtml(Tokenizer): def doConvert(self): """Convert the list of text tokens into a HTML document saved - to theResult. + to _result. """ if self._genMode == self.M_PREVIEW: htmlTags = { # HTML4 + CSS2 (for Qt) @@ -290,7 +292,7 @@ class ToHtml(Tokenizer): return - def saveHTML5(self, savePath): + def saveHTML5(self, savePath: str | Path): """Save the data to an .html file. """ with open(savePath, mode="w", encoding="utf-8") as outFile: @@ -324,9 +326,8 @@ class ToHtml(Tokenizer): return - def replaceTabs(self, nSpaces=8, spaceChar=" "): - """Replace tabs with spaces in the html. - """ + def replaceTabs(self, nSpaces: int = 8, spaceChar: str = " "): + """Replace tabs with spaces in the html.""" htmlText = [] tabSpace = spaceChar*nSpaces for aLine in self._fullHTML: @@ -335,20 +336,19 @@ class ToHtml(Tokenizer): self._fullHTML = htmlText return - def getStyleSheet(self): - """Generate a stylesheet appropriate for the current settings. - """ - theStyles = [] + def getStyleSheet(self) -> list: + """Generate a stylesheet for the current settings.""" + styles = [] if not self._cssStyles: - return theStyles + return styles mScale = self._lineHeight/1.15 textAlign = "justify" if self._doJustify else "left" - theStyles.append("body {{font-family: '{0:s}'; font-size: {1:d}pt;}}".format( + styles.append("body {{font-family: '{0:s}'; font-size: {1:d}pt;}}".format( self._textFont, self._textSize )) - theStyles.append(( + styles.append(( "p {{" "text-align: {0}; line-height: {1:d}%; " "margin-top: {2:.2f}em; margin-bottom: {3:.2f}em;" @@ -359,7 +359,7 @@ class ToHtml(Tokenizer): mScale * self._marginText[0], mScale * self._marginText[1], )) - theStyles.append(( + styles.append(( "h1 {{" "color: rgb(66, 113, 174); " "page-break-after: avoid; " @@ -369,7 +369,7 @@ class ToHtml(Tokenizer): ).format( mScale * self._marginHead1[0], mScale * self._marginHead1[1] )) - theStyles.append(( + styles.append(( "h2 {{" "color: rgb(66, 113, 174); " "page-break-after: avoid; " @@ -379,7 +379,7 @@ class ToHtml(Tokenizer): ).format( mScale * self._marginHead2[0], mScale * self._marginHead2[1] )) - theStyles.append(( + styles.append(( "h3 {{" "color: rgb(50, 50, 50); " "page-break-after: avoid; " @@ -389,7 +389,7 @@ class ToHtml(Tokenizer): ).format( mScale * self._marginHead3[0], mScale * self._marginHead3[1] )) - theStyles.append(( + styles.append(( "h4 {{" "color: rgb(50, 50, 50); " "page-break-after: avoid; " @@ -399,7 +399,7 @@ class ToHtml(Tokenizer): ).format( mScale * self._marginHead4[0], mScale * self._marginHead4[1] )) - theStyles.append(( + styles.append(( ".title {{" "font-size: 2.5em; " "margin-top: {0:.2f}em; " @@ -408,7 +408,7 @@ class ToHtml(Tokenizer): ).format( mScale * self._marginTitle[0], mScale * self._marginTitle[1] )) - theStyles.append(( + styles.append(( ".sep, .skip {{" "text-align: center; " "margin-top: {0:.2f}em; " @@ -418,61 +418,58 @@ class ToHtml(Tokenizer): mScale, mScale )) - theStyles.append("a {color: rgb(66, 113, 174);}") - theStyles.append(".tags {color: rgb(245, 135, 31); font-weight: bold;}") - theStyles.append(".break {text-align: left;}") - theStyles.append(".synopsis {font-style: italic;}") - theStyles.append(".comment {font-style: italic; color: rgb(100, 100, 100);}") + styles.append("a {color: rgb(66, 113, 174);}") + styles.append(".tags {color: rgb(245, 135, 31); font-weight: bold;}") + styles.append(".break {text-align: left;}") + styles.append(".synopsis {font-style: italic;}") + styles.append(".comment {font-style: italic; color: rgb(100, 100, 100);}") - return theStyles + return styles ## # Internal Functions ## - def _formatSynopsis(self, tText): - """Apply HTML formatting to synopsis. - """ + def _formatSynopsis(self, text: str) -> str: + """Apply HTML formatting to synopsis.""" if self._genMode == self.M_PREVIEW: sSynop = self._trSynopsis - return f"

{sSynop}: {tText}

\n" + return f"

{sSynop}: {text}

\n" else: sSynop = self._localLookup("Synopsis") - return f"

{sSynop}: {tText}

\n" + return f"

{sSynop}: {text}

\n" - def _formatComments(self, tText): - """Apply HTML formatting to comments. - """ + def _formatComments(self, text: str) -> str: + """Apply HTML formatting to comments.""" if self._genMode == self.M_PREVIEW: - return f"

{tText}

\n" + return f"

{text}

\n" else: sComm = self._localLookup("Comment") - return f"

{sComm}: {tText}

\n" + return f"

{sComm}: {text}

\n" - def _formatKeywords(self, tText): - """Apply HTML formatting to keywords. - """ - isValid, theBits, _ = self._project.index.scanThis("@"+tText) - if not isValid or not theBits: + def _formatKeywords(self, text: str) -> str: + """Apply HTML formatting to keywords.""" + valid, bits, _ = self._project.index.scanThis("@"+text) + if not valid or not bits: return "" - retText = "" - refTags = [] - if theBits[0] in nwLabels.KEY_NAME: - retText += f"{nwLabels.KEY_NAME[theBits[0]]}: " - if len(theBits) > 1: - if theBits[0] == nwKeyWords.TAG_KEY: - retText += f"{theBits[1]}" + result = "" + tags = [] + if bits[0] in nwLabels.KEY_NAME: + result += f"{nwLabels.KEY_NAME[bits[0]]}: " + if len(bits) > 1: + if bits[0] == nwKeyWords.TAG_KEY: + result += f"{bits[1]}" else: if self._genMode == self.M_PREVIEW: - for tTag in theBits[1:]: - refTags.append(f"{tTag}") - retText += ", ".join(refTags) + for tTag in bits[1:]: + tags.append(f"{tTag}") + result += ", ".join(tags) else: - for tTag in theBits[1:]: - refTags.append(f"{tTag}") - retText += ", ".join(refTags) + for tTag in bits[1:]: + tags.append(f"{tTag}") + result += ", ".join(tags) - return retText + return result # END Class ToHtml diff --git a/novelwriter/core/tokenizer.py b/novelwriter/core/tokenizer.py index 010695f0..34307a93 100644 --- a/novelwriter/core/tokenizer.py +++ b/novelwriter/core/tokenizer.py @@ -29,6 +29,7 @@ import re import logging from abc import ABC, abstractmethod +from pathlib import Path from operator import itemgetter from functools import partial @@ -102,7 +103,7 @@ class Tokenizer(ABC): self._result = "" # The result of the last document self._keepMarkdown = False # Whether to keep the markdown text - self._theMarkdown = [] # The result novelWriter markdown of all documents + self._allMarkdown = [] # The result novelWriter markdown of all documents # User Settings self._textFont = "Serif" # Output text font @@ -164,111 +165,136 @@ class Tokenizer(ABC): ## @property - def theResult(self): + def theResult(self) -> str: + """The result of the build process.""" return self._result @property - def theMarkdown(self): - return self._theMarkdown + def theMarkdown(self) -> list: + """The combined novelWriter Markdown text.""" + return self._allMarkdown @property - def errData(self): + def errData(self) -> list: + """The error data.""" return self._errData ## # Setters ## - def setTitleFormat(self, hFormat): + def setTitleFormat(self, hFormat: str): + """Set the title format pattern.""" self._fmtTitle = hFormat.strip() return - def setChapterFormat(self, hFormat): + def setChapterFormat(self, hFormat: str): + """Set the chapert format pattern.""" self._fmtChapter = hFormat.strip() return - def setUnNumberedFormat(self, hFormat): + def setUnNumberedFormat(self, hFormat: str): + """Set the unnumbered format pattern.""" self._fmtUnNum = hFormat.strip() return - def setSceneFormat(self, hFormat, hide): + def setSceneFormat(self, hFormat: str, hide: bool): + """Set the scene format pattern and hidden status.""" self._fmtScene = hFormat.strip() self._hideScene = hide return - def setSectionFormat(self, hFormat, hide): + def setSectionFormat(self, hFormat: str, hide: bool): + """Set the section format pattern and hidden status.""" self._fmtSection = hFormat.strip() self._hideSection = hide return - def setFont(self, textFont, textSize, textFixed=False): - self._textFont = textFont - self._textSize = round(int(textSize)) - self._textFixed = textFixed + def setFont(self, family: str, size: int, isFixed: bool = False): + """Set the build font.""" + self._textFont = family + self._textSize = round(int(size)) + self._textFixed = isFixed return - def setLineHeight(self, lineHeight): - self._lineHeight = min(max(float(lineHeight), 0.5), 5.0) + def setLineHeight(self, height: float): + """Set the line height between 0.5 and 5.0.""" + self._lineHeight = min(max(float(height), 0.5), 5.0) return - def setBlockIndent(self, blockIndent): - self._blockIndent = min(max(float(blockIndent), 0.0), 10.0) + def setBlockIndent(self, indent: float): + """Set the block indent between 0.0 and 10.0.""" + self._blockIndent = min(max(float(indent), 0.0), 10.0) return - def setJustify(self, doJustify): - self._doJustify = doJustify + def setJustify(self, state: bool): + """Enable or disable text justification.""" + self._doJustify = state return - def setTitleMargins(self, mUpper, mLower): - self._marginTitle = (float(mUpper), float(mLower)) + def setTitleMargins(self, upper: float, lower: float): + """Set the upper and lower title margin.""" + self._marginTitle = (float(upper), float(lower)) return - def setHead1Margins(self, mUpper, mLower): - self._marginHead1 = (float(mUpper), float(mLower)) + def setHead1Margins(self, upper: float, lower: float): + """Set the upper and lower header 1 margin.""" + self._marginHead1 = (float(upper), float(lower)) return - def setHead2Margins(self, mUpper, mLower): - self._marginHead2 = (float(mUpper), float(mLower)) + def setHead2Margins(self, upper: float, lower: float): + """Set the upper and lower header 2 margin.""" + self._marginHead2 = (float(upper), float(lower)) return - def setHead3Margins(self, mUpper, mLower): - self._marginHead3 = (float(mUpper), float(mLower)) + def setHead3Margins(self, upper: float, lower: float): + """Set the upper and lower header 3 margin.""" + self._marginHead3 = (float(upper), float(lower)) return - def setHead4Margins(self, mUpper, mLower): - self._marginHead4 = (float(mUpper), float(mLower)) + def setHead4Margins(self, upper: float, lower: float): + """Set the upper and lower header 4 margin.""" + self._marginHead4 = (float(upper), float(lower)) return - def setTextMargins(self, mUpper, mLower): - self._marginText = (float(mUpper), float(mLower)) + def setTextMargins(self, upper: float, lower: float): + """Set the upper and lower text margin.""" + self._marginText = (float(upper), float(lower)) return - def setMetaMargins(self, mUpper, mLower): - self._marginMeta = (float(mUpper), float(mLower)) + def setMetaMargins(self, upper: float, lower: float): + """Set the upper and lower meta text margin.""" + self._marginMeta = (float(upper), float(lower)) return - def setLinkHeaders(self, linkHeaders): - self._linkHeaders = linkHeaders + def setLinkHeaders(self, state: bool): + """Enable or disable adding an anchor before headers.""" + self._linkHeaders = state return - def setBodyText(self, doBodyText): - self._doBodyText = doBodyText + def setBodyText(self, state: bool): + """Include body text in build.""" + self._doBodyText = state return - def setSynopsis(self, doSynopsis): - self._doSynopsis = doSynopsis + def setSynopsis(self, state: bool): + """Include synopsis comments in build.""" + self._doSynopsis = state return - def setComments(self, doComments): - self._doComments = doComments + def setComments(self, state: bool): + """Include comments in build.""" + self._doComments = state return - def setKeywords(self, doKeywords): - self._doKeywords = doKeywords + def setKeywords(self, state: bool): + """Include keywords in build.""" + self._doKeywords = state return - def setKeepMarkdown(self, keepMarkdown): - self._keepMarkdown = keepMarkdown + def setKeepMarkdown(self, state: bool): + """Keep original markdown during build.""" + self._keepMarkdown = state return ## @@ -279,10 +305,9 @@ class Tokenizer(ABC): def doConvert(self): raise NotImplementedError - def addRootHeading(self, theHandle): - """Add a heading at the start of a new root folder. - """ - if not self._project.tree.checkType(theHandle, nwItemType.ROOT): + def addRootHeading(self, tHandle: str) -> bool: + """Add a heading at the start of a new root folder.""" + if not self._project.tree.checkType(tHandle, nwItemType.ROOT): return False if self._isFirst: @@ -291,7 +316,7 @@ class Tokenizer(ABC): else: textAlign = self.A_PBB | self.A_CENTRE - theItem = self._project.tree[theHandle] + theItem = self._project.tree[tHandle] locNotes = self._localLookup("Notes") theTitle = f"{locNotes}: {theItem.itemName}" self._tokens = [] @@ -299,22 +324,22 @@ class Tokenizer(ABC): self.T_TITLE, 0, theTitle, None, textAlign )) if self._keepMarkdown: - self._theMarkdown.append(f"# {theTitle}\n\n") + self._allMarkdown.append(f"# {theTitle}\n\n") return True - def setText(self, theHandle, theText=None): + def setText(self, tHandle: str, text: str | None = None) -> bool: """Set the text for the tokenizer from a handle. If theText is not set, load it from the file. """ - self._nwItem = self._project.tree[theHandle] + self._nwItem = self._project.tree[tHandle] if self._nwItem is None: return False - if theText is None: - theText = self._project.storage.getDocument(theHandle).readDocument() or "" + if text is None: + text = self._project.storage.getDocument(tHandle).readDocument() or "" - self._text = theText + self._text = text docSize = len(self._text) if docSize > nwConst.MAX_DOCSIZE: @@ -331,8 +356,7 @@ class Tokenizer(ABC): return True def doPreProcessing(self): - """Run trough the various replace doctionaries. - """ + """Run trough the various replace dictionaries.""" # Process the user's auto-replace dictionary autoReplace = self._project.data.autoReplace if len(autoReplace) > 0: @@ -582,7 +606,7 @@ class Tokenizer(ABC): tmpMarkdown.append("\n") if self._keepMarkdown: - self._theMarkdown.append("".join(tmpMarkdown)) + self._allMarkdown.append("".join(tmpMarkdown)) # Second Pass # =========== @@ -610,7 +634,7 @@ class Tokenizer(ABC): return - def doHeaders(self): + def doHeaders(self) -> bool: """Apply formatting to the text headers for novel files. This also applies chapter and scene numbering. """ @@ -708,11 +732,10 @@ class Tokenizer(ABC): return True - def saveRawMarkdown(self, savePath): - """Save the data to a plain text file. - """ + def saveRawMarkdown(self, savePath: str | Path): + """Save the data to a plain text file.""" with open(savePath, mode="w", encoding="utf-8") as outFile: - for nwdPage in self._theMarkdown: + for nwdPage in self._allMarkdown: outFile.write(nwdPage) return diff --git a/novelwriter/core/tomd.py b/novelwriter/core/tomd.py index b59aed6f..67eaf9a4 100644 --- a/novelwriter/core/tomd.py +++ b/novelwriter/core/tomd.py @@ -22,10 +22,14 @@ 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 . """ +from __future__ import annotations import logging +from pathlib import Path + from novelwriter.constants import nwLabels +from novelwriter.core.project import NWProject from novelwriter.core.tokenizer import Tokenizer logger = logging.getLogger(__name__) @@ -36,8 +40,8 @@ class ToMarkdown(Tokenizer): M_STD = 0 # Standard Markdown M_GH = 1 # GitHub Markdown - def __init__(self, theProject): - super().__init__(theProject) + def __init__(self, project: NWProject): + super().__init__(project) self._genMode = self.M_STD self._fullMD = [] @@ -49,7 +53,8 @@ class ToMarkdown(Tokenizer): ## @property - def fullMD(self): + def fullMD(self) -> list: + """Return the markdown as a list.""" return self._fullMD ## @@ -68,9 +73,8 @@ class ToMarkdown(Tokenizer): # Class Methods ## - def getFullResultSize(self): - """Return the size of the full Markdown result. - """ + def getFullResultSize(self) -> int: + """Return the size of the full Markdown result.""" return sum([len(x) for x in self._fullMD]) def doConvert(self): @@ -166,49 +170,36 @@ class ToMarkdown(Tokenizer): return - def saveMarkdown(self, savePath): - """Save the data to a plain text file. - """ - with open(savePath, mode="w", encoding="utf-8") as outFile: - theText = "".join(self._fullMD) - outFile.write(theText) - + def saveMarkdown(self, path: str | Path): + """Save the data to a plain text file.""" + with open(path, mode="w", encoding="utf-8") as outFile: + outFile.write("".join(self._fullMD)) return - def replaceTabs(self, nSpaces=8, spaceChar=" "): - """Replace tabs with spaces. - """ - fullMD = [] - eightSpace = spaceChar*nSpaces - for aPage in self._fullMD: - fullMD.append(aPage.replace("\t", eightSpace)) - - self._fullMD = fullMD + def replaceTabs(self, nSpaces: int = 8, spaceChar: str = " "): + """Replace tabs with spaces.""" + spaces = spaceChar*nSpaces + self._fullMD = [p.replace("\t", spaces) for p in self._fullMD] return ## # Internal Functions ## - def _formatKeywords(self, tText, tStyle): - """Apply Markdown formatting to keywords. - """ - isValid, theBits, _ = self._project.index.scanThis("@"+tText) - if not isValid or not theBits: + def _formatKeywords(self, text: str, style: int) -> str: + """Apply Markdown formatting to keywords.""" + valid, bits, _ = self._project.index.scanThis("@"+text) + if not valid or not bits: return "" - retText = "" - if theBits[0] in nwLabels.KEY_NAME: - retText += f"**{nwLabels.KEY_NAME[theBits[0]]}:** " + result = "" + if bits[0] in nwLabels.KEY_NAME: + result += f"**{nwLabels.KEY_NAME[bits[0]]}:** " + if len(bits) > 1: + result += ", ".join(bits[1:]) - if len(theBits) > 1: - retText += ", ".join(theBits[1:]) + result += " \n" if style & self.A_Z_BTMMRG else "\n\n" - if tStyle & self.A_Z_BTMMRG: - retText += " \n" - else: - retText += "\n\n" - - return retText + return result # END Class ToMarkdown From b09068ab99b3a600db21f8a7c70dee3d55981c80 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 6 Jun 2023 22:12:56 +0200 Subject: [PATCH 66/74] Add annotations to odt class --- novelwriter/core/tohtml.py | 6 +- novelwriter/core/tokenizer.py | 16 +- novelwriter/core/tomd.py | 7 +- novelwriter/core/toodt.py | 516 +++++++++--------- tests/reference/coreToOdt_SaveFull_styles.xml | 12 +- tests/test_core/test_core_toodt.py | 98 ++-- 6 files changed, 322 insertions(+), 333 deletions(-) diff --git a/novelwriter/core/tohtml.py b/novelwriter/core/tohtml.py index 323b617c..f02daafb 100644 --- a/novelwriter/core/tohtml.py +++ b/novelwriter/core/tohtml.py @@ -1,7 +1,6 @@ """ novelWriter – HTML Text Converter ================================= -Extends the Tokenizer class to generate HTML output File History: Created: 2019-05-07 [0.0.1] @@ -37,6 +36,11 @@ logger = logging.getLogger(__name__) class ToHtml(Tokenizer): + """Core: HTML Document Writer + + Extend the Tokenizer class to writer HTML output. This class is + also used by the Document Viewer, and Manuscript Build Preview. + """ M_PREVIEW = 0 # Tweak output for the DocViewer M_EXPORT = 1 # Tweak output for saving to HTML or printing diff --git a/novelwriter/core/tokenizer.py b/novelwriter/core/tokenizer.py index 34307a93..62a6a71f 100644 --- a/novelwriter/core/tokenizer.py +++ b/novelwriter/core/tokenizer.py @@ -1,7 +1,6 @@ """ novelWriter – Text Tokenizer ============================ -Split novelWriter plain text into its elements File History: Created: 2019-05-05 [0.0.1] Tokenizer @@ -55,6 +54,13 @@ def stripEscape(text): class Tokenizer(ABC): + """Core: Text Tokenizer Abstract Base Class + + This is the base class for all document build classes. It parses the + novelWriter markup format and generates a registry of tokens and + text that can be further processed into other output formats by + subclasses. + """ # In-Text Format FMT_B_B = 1 # Begin bold @@ -309,6 +315,9 @@ class Tokenizer(ABC): """Add a heading at the start of a new root folder.""" if not self._project.tree.checkType(tHandle, nwItemType.ROOT): return False + theItem = self._project.tree[tHandle] + if not theItem: + return False if self._isFirst: textAlign = self.A_CENTRE @@ -316,7 +325,6 @@ class Tokenizer(ABC): else: textAlign = self.A_PBB | self.A_CENTRE - theItem = self._project.tree[tHandle] locNotes = self._localLookup("Notes") theTitle = f"{locNotes}: {theItem.itemName}" self._tokens = [] @@ -781,10 +789,10 @@ class HeadingFormatter: chWord = self._project.localLookup(self._chCount) hFormat = hFormat.replace(nwHeadFmt.CH_WORD, chWord) if nwHeadFmt.CH_ROML in hFormat: - chRom = numberToRoman(self._chCount, True) + chRom = numberToRoman(self._chCount, toLower=True) hFormat = hFormat.replace(nwHeadFmt.CH_ROML, chRom) if nwHeadFmt.CH_ROMU in hFormat: - chRom = numberToRoman(self._chCount, False) + chRom = numberToRoman(self._chCount, toLower=False) hFormat = hFormat.replace(nwHeadFmt.CH_ROMU, chRom) return hFormat diff --git a/novelwriter/core/tomd.py b/novelwriter/core/tomd.py index 67eaf9a4..8e05c40b 100644 --- a/novelwriter/core/tomd.py +++ b/novelwriter/core/tomd.py @@ -1,7 +1,6 @@ """ novelWriter – Markdown Text Converter ===================================== -Extends the Tokenizer class to generate Makrdown output File History: Created: 2021-02-06 [1.2b1] @@ -36,6 +35,12 @@ logger = logging.getLogger(__name__) class ToMarkdown(Tokenizer): + """Core: Markdown Document Writer + + Extend the Tokenizer class to writer Markdown output. It supports + both Standard Markdown and GitHub Flavour Markdown (Extended). The + class also supports concatenating novelWriter markup files. + """ M_STD = 0 # Standard Markdown M_GH = 1 # GitHub Markdown diff --git a/novelwriter/core/toodt.py b/novelwriter/core/toodt.py index b67d7a64..888e4e42 100644 --- a/novelwriter/core/toodt.py +++ b/novelwriter/core/toodt.py @@ -1,7 +1,6 @@ """ novelWriter – ODT Text Converter ================================ -Extends the Tokenizer class to generate ODT and FODT files File History: Created: 2021-01-26 [1.2b1] ToOdt @@ -25,8 +24,10 @@ 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 . """ +from __future__ import annotations import logging +from pathlib import Path import xml.etree.ElementTree as ET from hashlib import sha256 @@ -36,6 +37,7 @@ from datetime import datetime from novelwriter import __version__ from novelwriter.common import xmlIndent from novelwriter.constants import nwKeyWords, nwLabels +from novelwriter.core.project import NWProject from novelwriter.core.tokenizer import Tokenizer, stripEscape logger = logging.getLogger(__name__) @@ -55,9 +57,8 @@ for ns, uri in XML_NS.items(): ET.register_namespace(ns, uri) -def _mkTag(ns, tag): - """Assemble namespace and tag name. - """ +def _mkTag(ns: str, tag: str) -> str: + """Assemble namespace and tag name.""" uri = XML_NS.get(ns, "") if uri: return f"{{{uri}}}{tag}" @@ -89,9 +90,16 @@ M_DEL = ~X_DEL class ToOdt(Tokenizer): + """Core: Open Document Writer - def __init__(self, theProject, isFlat): - super().__init__(theProject) + Extend the Tokenizer class to writer Open Document files. The output + should conform to the 1.3 Extended standard. + + Test with: https://odfvalidator.org/ + """ + + def __init__(self, project: NWProject, isFlat: bool): + super().__init__(project) self._isFlat = isFlat # Flat: .fodt, otherwise .odt @@ -178,36 +186,26 @@ class ToOdt(Tokenizer): # Setters ## - def setLanguage(self, theLang): - """Set language for the document. - """ - if theLang is None: - return False + def setLanguage(self, language: str): + """Set language for the document.""" + if language: + langBits = language.split("_") + self._dLanguage = langBits[0] + if len(langBits) > 1: + self._dCountry = langBits[1] + return - langBits = theLang.split("_") - self._dLanguage = langBits[0] - if len(langBits) > 1: - self._dCountry = langBits[1] - - return True - - def setColourHeaders(self, doColour): - """Enable/disable coloured headings and comments. - """ - self._colourHead = doColour + def setColourHeaders(self, state: bool): + """Enable/disable coloured headings and comments.""" + self._colourHead = state return ## # Class Methods ## - def getErrors(self): - """Return the list of errors.""" - return self._errData - def initDocument(self): - """Initialises a new open document XML tree. - """ + """Initialises a new open document XML tree.""" # Initialise Variables # ==================== @@ -369,8 +367,7 @@ class ToOdt(Tokenizer): return def doConvert(self): - """Convert the list of text tokens into XML elements. - """ + """Convert the list of text tokens into XML elements.""" self._result = "" # Not used, but cleared just in case odtTags = { @@ -421,12 +418,12 @@ class ToOdt(Tokenizer): if self._doJustify: parStyle.setTextAlign("left") - if len(thisPar) > 0: + if len(thisPar) > 0 and parStyle is not None: tTemp = "\n".join(thisPar) fTemp = " ".join(thisFmt) tTxt = tTemp.rstrip() tFmt = fTemp[:len(tTxt)] - self._addTextPar("Text_20_body", parStyle, tTxt, theFmt=tFmt) + self._addTextPar("Text_20_body", parStyle, tTxt, tFmt=tFmt) thisPar = [] thisFmt = [] @@ -477,41 +474,37 @@ class ToOdt(Tokenizer): elif tType == self.T_SYNOPSIS and self._doSynopsis: tTemp, fTemp = self._formatSynopsis(tText) - self._addTextPar("Text_20_Meta", oStyle, tTemp, theFmt=fTemp) + self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=fTemp) elif tType == self.T_COMMENT and self._doComments: tTemp, fTemp = self._formatComments(tText) - self._addTextPar("Text_20_Meta", oStyle, tTemp, theFmt=fTemp) + self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=fTemp) elif tType == self.T_KEYWORD and self._doKeywords: tTemp, fTemp = self._formatKeywords(tText) - self._addTextPar("Text_20_Meta", oStyle, tTemp, theFmt=fTemp) + self._addTextPar("Text_20_Meta", oStyle, tTemp, tFmt=fTemp) return def closeDocument(self): - """Return the serialised XML document - """ + """Return the serialised XML document""" # Build the auto-generated styles for styleName, styleObj in self._autoPara.values(): styleObj.packXML(self._xAuto, styleName) for styleName, styleObj in self._autoText.values(): styleObj.packXML(self._xAuto, styleName) - return - def saveFlatXML(self, savePath): - """Save the data to an .fodt file. - """ - with open(savePath, mode="wb") as outFile: + def saveFlatXML(self, path: str | Path): + """Save the data to an .fodt file.""" + with open(path, mode="wb") as fObj: xml = ET.ElementTree(self._dFlat) xmlIndent(xml) - xml.write(outFile, encoding="utf-8", xml_declaration=True) + xml.write(fObj, encoding="utf-8", xml_declaration=True) return - def saveOpenDocText(self, savePath): - """Save the data to an .odt file. - """ + def saveOpenDocText(self, path: str | Path): + """Save the data to an .odt file.""" mMani = _mkTag("manifest", "manifest") mVers = _mkTag("manifest", "version") mPath = _mkTag("manifest", "full-path") @@ -534,7 +527,7 @@ class ToOdt(Tokenizer): xml = ET.ElementTree(xObj) xml.write(fObj, encoding="utf-8", xml_declaration=True) - with ZipFile(savePath, mode="w") as outZip: + with ZipFile(path, mode="w") as outZip: outZip.writestr("mimetype", X_MIME) putInZip("META-INF/manifest.xml", xMani, outZip) putInZip("settings.xml", xSett, outZip) @@ -548,49 +541,48 @@ class ToOdt(Tokenizer): # Internal Functions ## - def _formatSynopsis(self, tText): - """Apply formatting to synopsis lines. - """ + def _formatSynopsis(self, text: str) -> tuple[str, str]: + """Apply formatting to synopsis lines.""" sSynop = self._localLookup("Synopsis") - rTxt = "**{0}:** {1}".format(sSynop, tText) - rFmt = "_B{0} b_ {1}".format(" "*len(sSynop), " "*len(tText)) + rTxt = "**{0}:** {1}".format(sSynop, text) + rFmt = "_B{0} b_ {1}".format(" "*len(sSynop), " "*len(text)) return rTxt, rFmt - def _formatComments(self, tText): - """Apply formatting to comments. - """ + def _formatComments(self, text: str) -> tuple[str, str]: + """Apply formatting to comments.""" sComm = self._localLookup("Comment") - rTxt = "**{0}:** {1}".format(sComm, tText) - rFmt = "_B{0} b_ {1}".format(" "*len(sComm), " "*len(tText)) + rTxt = "**{0}:** {1}".format(sComm, text) + rFmt = "_B{0} b_ {1}".format(" "*len(sComm), " "*len(text)) return rTxt, rFmt - def _formatKeywords(self, tText): - """Apply formatting to keywords. - """ - isValid, theBits, _ = self._project.index.scanThis("@"+tText) - if not isValid or not theBits: - return "" + def _formatKeywords(self, text: str) -> tuple[str, str]: + """Apply formatting to keywords.""" + valid, bits, _ = self._project.index.scanThis("@"+text) + if not valid or not bits: + return "", "" rTxt = "" rFmt = "" - if theBits[0] in nwLabels.KEY_NAME: - tText = nwLabels.KEY_NAME[theBits[0]] - rTxt += "**{0}:** ".format(tText) - rFmt += "_B{0} b_ ".format(" "*len(tText)) - if len(theBits) > 1: - if theBits[0] == nwKeyWords.TAG_KEY: - rTxt += theBits[1] - rFmt += " "*len(theBits[1]) + if bits[0] in nwLabels.KEY_NAME: + text = nwLabels.KEY_NAME[bits[0]] + rTxt += "**{0}:** ".format(text) + rFmt += "_B{0} b_ ".format(" "*len(text)) + if len(bits) > 1: + if bits[0] == nwKeyWords.TAG_KEY: + rTxt += bits[1] + rFmt += " "*len(bits[1]) else: - tTags = ", ".join(theBits[1:]) + tTags = ", ".join(bits[1:]) rTxt += tTags rFmt += (" "*len(tTags)) return rTxt, rFmt - def _addTextPar(self, styleName, oStyle, theText, theFmt="", isHead=False, oLevel=None): - """Add a text paragraph to the text XML element. - """ + def _addTextPar( + self, styleName: str, oStyle: ODTParagraphStyle, tText: str, tFmt: str = "", + isHead: bool = False, oLevel: str | None = None + ): + """Add a text paragraph to the text XML element.""" tAttr = {} tAttr[_mkTag("text", "style-name")] = self._paraStyle(styleName, oStyle) if oLevel is not None: @@ -603,16 +595,16 @@ class ToOdt(Tokenizer): # xmlIndent will add a line break if the first subelement is a span. xElem.text = "" - if not theText: + if not tText: return ## # Process Formatting ## - if len(theText) != len(theFmt): + if len(tText) != len(tFmt): # Generate an empty format if there isn't any or it doesn't match - theFmt = " "*len(theText) + tFmt = " "*len(tText) # The formatting loop tTemp = "" @@ -622,23 +614,23 @@ class ToOdt(Tokenizer): parProc = XMLParagraph(xElem) - for i, c in enumerate(theText): + for i, c in enumerate(tText): - if theFmt[i] == " ": + if tFmt[i] == " ": tTemp += c - elif theFmt[i] == "_": + elif tFmt[i] == "_": continue - elif theFmt[i] == "B": + elif tFmt[i] == "B": xFmt |= X_BLD - elif theFmt[i] == "b": + elif tFmt[i] == "b": xFmt &= M_BLD - elif theFmt[i] == "I": + elif tFmt[i] == "I": xFmt |= X_ITA - elif theFmt[i] == "i": + elif tFmt[i] == "i": xFmt &= M_ITA - elif theFmt[i] == "S": + elif tFmt[i] == "S": xFmt |= X_DEL - elif theFmt[i] == "s": + elif tFmt[i] == "s": xFmt &= M_DEL else: pErr += 1 @@ -669,9 +661,8 @@ class ToOdt(Tokenizer): return - def _paraStyle(self, parName, oStyle): - """Return a name for a style object. - """ + def _paraStyle(self, parName: str, oStyle: ODTParagraphStyle) -> str: + """Return a name for a style object.""" refStyle = self._mainPara.get(parName, None) if refStyle is None: logger.error("Unknown paragraph style '%s'", parName) @@ -690,29 +681,27 @@ class ToOdt(Tokenizer): return newName - def _textStyle(self, tFmt): - """Return a text style for a given style code. - """ - if tFmt in self._autoText: - return self._autoText[tFmt][0] + def _textStyle(self, hFmt: int) -> str: + """Return a text style for a given style code.""" + if hFmt in self._autoText: + return self._autoText[hFmt][0] newName = "T%d" % (len(self._autoText) + 1) newStyle = ODTTextStyle() - if tFmt & X_BLD: + if hFmt & X_BLD: newStyle.setFontWeight("bold") - if tFmt & X_ITA: + if hFmt & X_ITA: newStyle.setFontStyle("italic") - if tFmt & X_DEL: + if hFmt & X_DEL: newStyle.setStrikeStyle("solid") newStyle.setStrikeType("single") - self._autoText[tFmt] = (newName, newStyle) + self._autoText[hFmt] = (newName, newStyle) return newName - def _emToCm(self, emVal): - """Converts an em value to centimetres. - """ + def _emToCm(self, emVal: float) -> str: + """Converts an em value to centimetres.""" return f"{emVal*2.54/72*self._textSize:.3f}cm" ## @@ -720,110 +709,107 @@ class ToOdt(Tokenizer): ## def _pageStyles(self): - """Set the default page style. - """ - theAttr = {} - theAttr[_mkTag("style", "name")] = "PM1" + """Set the default page style.""" + tAttr = {} + tAttr[_mkTag("style", "name")] = "PM1" if self._isFlat: - xPage = ET.SubElement(self._xAuto, _mkTag("style", "page-layout"), attrib=theAttr) + xPage = ET.SubElement(self._xAuto, _mkTag("style", "page-layout"), attrib=tAttr) else: - xPage = ET.SubElement(self._xAut2, _mkTag("style", "page-layout"), attrib=theAttr) + xPage = ET.SubElement(self._xAut2, _mkTag("style", "page-layout"), attrib=tAttr) - theAttr = {} - theAttr[_mkTag("fo", "margin-top")] = self._mDocTop - theAttr[_mkTag("fo", "margin-bottom")] = self._mDocBtm - theAttr[_mkTag("fo", "margin-left")] = self._mDocLeft - theAttr[_mkTag("fo", "margin-right")] = self._mDocRight - ET.SubElement(xPage, _mkTag("style", "page-layout-properties"), attrib=theAttr) + tAttr = {} + tAttr[_mkTag("fo", "margin-top")] = self._mDocTop + tAttr[_mkTag("fo", "margin-bottom")] = self._mDocBtm + tAttr[_mkTag("fo", "margin-left")] = self._mDocLeft + tAttr[_mkTag("fo", "margin-right")] = self._mDocRight + ET.SubElement(xPage, _mkTag("style", "page-layout-properties"), attrib=tAttr) xHead = ET.SubElement(xPage, _mkTag("style", "header-style")) - theAttr = {} - theAttr[_mkTag("fo", "min-height")] = "0.600cm" - theAttr[_mkTag("fo", "margin-left")] = "0.000cm" - theAttr[_mkTag("fo", "margin-right")] = "0.000cm" - theAttr[_mkTag("fo", "margin-bottom")] = "0.500cm" - ET.SubElement(xHead, _mkTag("style", "header-footer-properties"), attrib=theAttr) + tAttr = {} + tAttr[_mkTag("fo", "min-height")] = "0.600cm" + tAttr[_mkTag("fo", "margin-left")] = "0.000cm" + tAttr[_mkTag("fo", "margin-right")] = "0.000cm" + tAttr[_mkTag("fo", "margin-bottom")] = "0.500cm" + ET.SubElement(xHead, _mkTag("style", "header-footer-properties"), attrib=tAttr) return def _defaultStyles(self): - """Set the default styles. - """ + """Set the default styles.""" # Add Paragraph Family Style # ========================== - theAttr = {} - theAttr[_mkTag("style", "family")] = "paragraph" - xStyl = ET.SubElement(self._xStyl, _mkTag("style", "default-style"), attrib=theAttr) + tAttr = {} + tAttr[_mkTag("style", "family")] = "paragraph" + xStyl = ET.SubElement(self._xStyl, _mkTag("style", "default-style"), attrib=tAttr) - theAttr = {} - theAttr[_mkTag("style", "line-break")] = "strict" - theAttr[_mkTag("style", "tab-stop-distance")] = "1.251cm" - theAttr[_mkTag("style", "writing-mode")] = "page" - ET.SubElement(xStyl, _mkTag("style", "paragraph-properties"), attrib=theAttr) + tAttr = {} + tAttr[_mkTag("style", "line-break")] = "strict" + tAttr[_mkTag("style", "tab-stop-distance")] = "1.251cm" + tAttr[_mkTag("style", "writing-mode")] = "page" + ET.SubElement(xStyl, _mkTag("style", "paragraph-properties"), attrib=tAttr) - theAttr = {} - theAttr[_mkTag("style", "font-name")] = self._textFont - theAttr[_mkTag("fo", "font-family")] = self._fontFamily - theAttr[_mkTag("fo", "font-size")] = self._fSizeText - theAttr[_mkTag("fo", "language")] = self._dLanguage - theAttr[_mkTag("fo", "country")] = self._dCountry - ET.SubElement(xStyl, _mkTag("style", "text-properties"), attrib=theAttr) + tAttr = {} + tAttr[_mkTag("style", "font-name")] = self._textFont + tAttr[_mkTag("fo", "font-family")] = self._fontFamily + tAttr[_mkTag("fo", "font-size")] = self._fSizeText + tAttr[_mkTag("fo", "language")] = self._dLanguage + tAttr[_mkTag("fo", "country")] = self._dCountry + ET.SubElement(xStyl, _mkTag("style", "text-properties"), attrib=tAttr) # Add Standard Paragraph Style # ============================ - theAttr = {} - theAttr[_mkTag("style", "name")] = "Standard" - theAttr[_mkTag("style", "family")] = "paragraph" - theAttr[_mkTag("style", "class")] = "text" - xStyl = ET.SubElement(self._xStyl, _mkTag("style", "style"), attrib=theAttr) + tAttr = {} + tAttr[_mkTag("style", "name")] = "Standard" + tAttr[_mkTag("style", "family")] = "paragraph" + tAttr[_mkTag("style", "class")] = "text" + xStyl = ET.SubElement(self._xStyl, _mkTag("style", "style"), attrib=tAttr) - theAttr = {} - theAttr[_mkTag("style", "font-name")] = self._textFont - theAttr[_mkTag("fo", "font-family")] = self._fontFamily - theAttr[_mkTag("fo", "font-size")] = self._fSizeText - ET.SubElement(xStyl, _mkTag("style", "text-properties"), attrib=theAttr) + tAttr = {} + tAttr[_mkTag("style", "font-name")] = self._textFont + tAttr[_mkTag("fo", "font-family")] = self._fontFamily + tAttr[_mkTag("fo", "font-size")] = self._fSizeText + ET.SubElement(xStyl, _mkTag("style", "text-properties"), attrib=tAttr) # Add Default Heading Style # ========================= - theAttr = {} - theAttr[_mkTag("style", "name")] = "Heading" - theAttr[_mkTag("style", "family")] = "paragraph" - theAttr[_mkTag("style", "parent-style-name")] = "Standard" - theAttr[_mkTag("style", "next-style-name")] = "Text_20_body" - theAttr[_mkTag("style", "class")] = "text" - xStyl = ET.SubElement(self._xStyl, _mkTag("style", "style"), attrib=theAttr) + tAttr = {} + tAttr[_mkTag("style", "name")] = "Heading" + tAttr[_mkTag("style", "family")] = "paragraph" + tAttr[_mkTag("style", "parent-style-name")] = "Standard" + tAttr[_mkTag("style", "next-style-name")] = "Text_20_body" + tAttr[_mkTag("style", "class")] = "text" + xStyl = ET.SubElement(self._xStyl, _mkTag("style", "style"), attrib=tAttr) - theAttr = {} - theAttr[_mkTag("fo", "margin-top")] = self._mTopHead - theAttr[_mkTag("fo", "margin-bottom")] = self._mBotHead - theAttr[_mkTag("fo", "keep-with-next")] = "always" - ET.SubElement(xStyl, _mkTag("style", "paragraph-properties"), attrib=theAttr) + tAttr = {} + tAttr[_mkTag("fo", "margin-top")] = self._mTopHead + tAttr[_mkTag("fo", "margin-bottom")] = self._mBotHead + tAttr[_mkTag("fo", "keep-with-next")] = "always" + ET.SubElement(xStyl, _mkTag("style", "paragraph-properties"), attrib=tAttr) - theAttr = {} - theAttr[_mkTag("style", "font-name")] = self._textFont - theAttr[_mkTag("fo", "font-family")] = self._fontFamily - theAttr[_mkTag("fo", "font-size")] = self._fSizeHead - ET.SubElement(xStyl, _mkTag("style", "text-properties"), attrib=theAttr) + tAttr = {} + tAttr[_mkTag("style", "font-name")] = self._textFont + tAttr[_mkTag("fo", "font-family")] = self._fontFamily + tAttr[_mkTag("fo", "font-size")] = self._fSizeHead + ET.SubElement(xStyl, _mkTag("style", "text-properties"), attrib=tAttr) # Add Header and Footer Styles # ============================ - theAttr = {} - theAttr[_mkTag("style", "name")] = "Header_20_and_20_Footer" - theAttr[_mkTag("style", "display-name")] = "Header and Footer" - theAttr[_mkTag("style", "family")] = "paragraph" - theAttr[_mkTag("style", "parent-style-name")] = "Standard" - theAttr[_mkTag("style", "class")] = "extra" - ET.SubElement(self._xStyl, _mkTag("style", "style"), attrib=theAttr) + tAttr = {} + tAttr[_mkTag("style", "name")] = "Header_20_and_20_Footer" + tAttr[_mkTag("style", "display-name")] = "Header and Footer" + tAttr[_mkTag("style", "family")] = "paragraph" + tAttr[_mkTag("style", "parent-style-name")] = "Standard" + tAttr[_mkTag("style", "class")] = "extra" + ET.SubElement(self._xStyl, _mkTag("style", "style"), attrib=tAttr) return def _useableStyles(self): - """Set the usable styles. - """ + """Set the usable styles.""" # Add Text Body Style # =================== @@ -977,12 +963,11 @@ class ToOdt(Tokenizer): return def _writeHeader(self): - """Write the header elements. - """ - theAttr = {} - theAttr[_mkTag("style", "name")] = "Standard" - theAttr[_mkTag("style", "page-layout-name")] = "PM1" - xPage = ET.SubElement(self._xMast, _mkTag("style", "master-page"), attrib=theAttr) + """Write the header elements.""" + tAttr = {} + tAttr[_mkTag("style", "name")] = "Standard" + tAttr[_mkTag("style", "page-layout-name")] = "PM1" + xPage = ET.SubElement(self._xMast, _mkTag("style", "master-page"), attrib=tAttr) # Standard Page Header xHead = ET.SubElement(xPage, _mkTag("style", "header")) @@ -1062,28 +1047,28 @@ class ODTParagraphStyle: # Attribute Setters ## - def setDisplayName(self, theValue): - self._mAttr["display-name"][1] = str(theValue) + def setDisplayName(self, value: str | None): + self._mAttr["display-name"][1] = value return - def setParentStyleName(self, theValue): - self._mAttr["parent-style-name"][1] = str(theValue) + def setParentStyleName(self, value: str | None): + self._mAttr["parent-style-name"][1] = value return - def setNextStyleName(self, theValue): - self._mAttr["next-style-name"][1] = str(theValue) + def setNextStyleName(self, value: str | None): + self._mAttr["next-style-name"][1] = value return - def setOutlineLevel(self, theValue): - if theValue in self.VALID_LEVEL: - self._mAttr["default-outline-level"][1] = str(theValue) + def setOutlineLevel(self, value: str | None): + if value in self.VALID_LEVEL: + self._mAttr["default-outline-level"][1] = value else: self._mAttr["default-outline-level"][1] = None return - def setClass(self, theValue): - if theValue in self.VALID_CLASS: - self._mAttr["class"][1] = str(theValue) + def setClass(self, value: str | None): + if value in self.VALID_CLASS: + self._mAttr["class"][1] = value else: self._mAttr["class"][1] = None return @@ -1092,43 +1077,43 @@ class ODTParagraphStyle: # Paragraph Setters ## - def setMarginTop(self, theValue): - self._pAttr["margin-top"][1] = str(theValue) + def setMarginTop(self, value: str | None): + self._pAttr["margin-top"][1] = value return - def setMarginBottom(self, theValue): - self._pAttr["margin-bottom"][1] = str(theValue) + def setMarginBottom(self, value: str | None): + self._pAttr["margin-bottom"][1] = value return - def setMarginLeft(self, theValue): - self._pAttr["margin-left"][1] = str(theValue) + def setMarginLeft(self, value: str | None): + self._pAttr["margin-left"][1] = value return - def setMarginRight(self, theValue): - self._pAttr["margin-right"][1] = str(theValue) + def setMarginRight(self, value: str | None): + self._pAttr["margin-right"][1] = value return - def setLineHeight(self, theValue): - self._pAttr["line-height"][1] = str(theValue) + def setLineHeight(self, value: str | None): + self._pAttr["line-height"][1] = value return - def setTextAlign(self, theValue): - if theValue in self.VALID_ALIGN: - self._pAttr["text-align"][1] = str(theValue) + def setTextAlign(self, value: str | None): + if value in self.VALID_ALIGN: + self._pAttr["text-align"][1] = value else: self._pAttr["text-align"][1] = None return - def setBreakBefore(self, theValue): - if theValue in self.VALID_BREAK: - self._pAttr["break-before"][1] = str(theValue) + def setBreakBefore(self, value: str | None): + if value in self.VALID_BREAK: + self._pAttr["break-before"][1] = value else: self._pAttr["break-before"][1] = None return - def setBreakAfter(self, theValue): - if theValue in self.VALID_BREAK: - self._pAttr["break-after"][1] = str(theValue) + def setBreakAfter(self, value: str | None): + if value in self.VALID_BREAK: + self._pAttr["break-after"][1] = value else: self._pAttr["break-after"][1] = None return @@ -1137,38 +1122,38 @@ class ODTParagraphStyle: # Text Setters ## - def setFontName(self, theValue): - self._tAttr["font-name"][1] = str(theValue) + def setFontName(self, value: str | None): + self._tAttr["font-name"][1] = value return - def setFontFamily(self, theValue): - self._tAttr["font-family"][1] = str(theValue) + def setFontFamily(self, value: str | None): + self._tAttr["font-family"][1] = value return - def setFontSize(self, theValue): - self._tAttr["font-size"][1] = str(theValue) + def setFontSize(self, value: str | None): + self._tAttr["font-size"][1] = value return - def setFontWeight(self, theValue): - if theValue in self.VALID_WEIGHT: - self._tAttr["font-weight"][1] = str(theValue) + def setFontWeight(self, value: str | None): + if value in self.VALID_WEIGHT: + self._tAttr["font-weight"][1] = value else: self._tAttr["font-weight"][1] = None return - def setColor(self, theValue): - self._tAttr["color"][1] = str(theValue) + def setColor(self, value: str | None): + self._tAttr["color"][1] = value return - def setOpacity(self, theValue): - self._tAttr["opacity"][1] = str(theValue) + def setOpacity(self, value: str | None): + self._tAttr["opacity"][1] = value return ## # Methods ## - def checkNew(self, refStyle): + def checkNew(self, refStyle: ODTParagraphStyle): """Check if there are new settings in refStyle that differ from those in the current object. """ @@ -1183,9 +1168,8 @@ class ODTParagraphStyle: return True return False - def getID(self): - """Generate a unique ID from the settings. - """ + def getID(self) -> str: + """Generate a unique ID from the settings.""" theString = ( f"Paragraph:Main:{str(self._mAttr)}:" f"Paragraph:Para:{str(self._pAttr)}:" @@ -1193,11 +1177,10 @@ class ODTParagraphStyle: ) return sha256(theString.encode()).hexdigest() - def packXML(self, xParent, xName): - """Pack the content into an xml element. - """ + def packXML(self, xParent: ET.Element, name: str): + """Pack the content into an xml element.""" theAttr = {} - theAttr[_mkTag("style", "name")] = xName + theAttr[_mkTag("style", "name")] = name theAttr[_mkTag("style", "family")] = "paragraph" for aName, (aNm, aVal) in self._mAttr.items(): if aVal is not None: @@ -1252,30 +1235,30 @@ class ODTTextStyle: # Setters ## - def setFontWeight(self, theValue): - if theValue in self.VALID_WEIGHT: - self._tAttr["font-weight"][1] = str(theValue) + def setFontWeight(self, value: str | None): + if value in self.VALID_WEIGHT: + self._tAttr["font-weight"][1] = value else: self._tAttr["font-weight"][1] = None return - def setFontStyle(self, theValue): - if theValue in self.VALID_STYLE: - self._tAttr["font-style"][1] = str(theValue) + def setFontStyle(self, value: str | None): + if value in self.VALID_STYLE: + self._tAttr["font-style"][1] = value else: self._tAttr["font-style"][1] = None return - def setStrikeStyle(self, theValue): - if theValue in self.VALID_LSTYLE: - self._tAttr["text-line-through-style"][1] = str(theValue) + def setStrikeStyle(self, value: str | None): + if value in self.VALID_LSTYLE: + self._tAttr["text-line-through-style"][1] = value else: self._tAttr["text-line-through-style"][1] = None return - def setStrikeType(self, theValue): - if theValue in self.VALID_LTYPE: - self._tAttr["text-line-through-type"][1] = str(theValue) + def setStrikeType(self, value: str | None): + if value in self.VALID_LTYPE: + self._tAttr["text-line-through-type"][1] = value else: self._tAttr["text-line-through-type"][1] = None return @@ -1284,11 +1267,10 @@ class ODTTextStyle: # Methods ## - def packXML(self, xParent, xName): - """Pack the content into an xml element. - """ + def packXML(self, xParent: ET.Element, name: str): + """Pack the content into an xml element.""" theAttr = {} - theAttr[_mkTag("style", "name")] = xName + theAttr[_mkTag("style", "name")] = name theAttr[_mkTag("style", "family")] = "text" xEntry = ET.SubElement(xParent, _mkTag("style", "style"), attrib=theAttr) @@ -1335,7 +1317,7 @@ class XMLParagraph: object and attribute is written to, """ - def __init__(self, xRoot): + def __init__(self, xRoot: ET.Element): self._xRoot = xRoot self._xTail = ET.Element("") @@ -1348,17 +1330,17 @@ class XMLParagraph: return - def appendText(self, text): + def appendText(self, tText: str): """Append text to the XML element. We do this one character at the time in order to be able to process line breaks, tabs and - spaces separately. Multiple spaces above one are concatenated - into a single tag, and must therefore be processed separately. + spaces separately. Multiple spaces are concatenated into a + single tag, and must therefore be processed separately. """ - text = stripEscape(text) + tText = stripEscape(tText) nSpaces = 0 - self._rawTxt += text + self._rawTxt += tText - for c in text: + for c in tText: if c == " ": nSpaces += 1 continue @@ -1395,38 +1377,36 @@ class XMLParagraph: else: if self._nState == X_ROOT_TEXT: - self._xRoot.text += c + self._xRoot.text = (self._xRoot.text or "") + c self._chrPos += 1 elif self._nState == X_ROOT_TAIL: - self._xTail.tail += c + self._xTail.tail = (self._xTail.tail or "") + c self._chrPos += 1 elif self._nState == X_SPAN_TEXT: - self._xTail.text += c + self._xTail.text = (self._xTail.text or "") + c self._chrPos += 1 elif self._nState == X_SPAN_SING: - self._xSing.tail += c + self._xSing.tail = (self._xSing.tail or "") + c self._chrPos += 1 if nSpaces > 0: + # Handle trailing spaces self._processSpaces(nSpaces) return - def appendSpan(self, tText, tFmt): + def appendSpan(self, tText: str, tFmt: str): """Append a text span to the XML element. The span is always closed since we do not allow nested spans (like Libre Office). Therefore we return to the root element level when we're done processing the text of the span. """ - self._xTail = ET.SubElement(self._xRoot, TAG_SPAN, attrib={ - TAG_STNM: tFmt - }) + self._xTail = ET.SubElement(self._xRoot, TAG_SPAN, attrib={TAG_STNM: tFmt}) self._xTail.text = "" # Defaults to None self._xTail.tail = "" # Defaults to None self._nState = X_SPAN_TEXT self.appendText(tText) self._nState = X_ROOT_TAIL - return def checkError(self): @@ -1443,7 +1423,7 @@ class XMLParagraph: # Internal Functions ## - def _processSpaces(self, nSpaces): + def _processSpaces(self, nSpaces: int): """Add spaces to paragraph. The first space is always written as-is (unless it's the first character of the paragraph). The second space uses the dedicated tag for spaces, and from the @@ -1455,16 +1435,16 @@ class XMLParagraph: if nSpaces > 0: if self._chrPos > 0: if self._nState == X_ROOT_TEXT: - self._xRoot.text += " " + self._xRoot.text = (self._xRoot.text or "") + " " self._chrPos += 1 elif self._nState == X_ROOT_TAIL: - self._xTail.tail += " " + self._xTail.tail = (self._xTail.tail or "") + " " self._chrPos += 1 elif self._nState == X_SPAN_TEXT: - self._xTail.text += " " + self._xTail.text = (self._xTail.text or "") + " " self._chrPos += 1 elif self._nState == X_SPAN_SING: - self._xSing.tail += " " + self._xSing.tail = (self._xSing.tail or "") + " " self._chrPos += 1 else: nSpaces += 1 diff --git a/tests/reference/coreToOdt_SaveFull_styles.xml b/tests/reference/coreToOdt_SaveFull_styles.xml index 6c5f9bd2..5f2d1db6 100644 --- a/tests/reference/coreToOdt_SaveFull_styles.xml +++ b/tests/reference/coreToOdt_SaveFull_styles.xml @@ -1,5 +1,5 @@ - + @@ -22,7 +22,7 @@ - + @@ -30,19 +30,19 @@ - + - + - + - + diff --git a/tests/test_core/test_core_toodt.py b/tests/test_core/test_core_toodt.py index 015e97b8..5ab6f276 100644 --- a/tests/test_core/test_core_toodt.py +++ b/tests/test_core/test_core_toodt.py @@ -43,8 +43,7 @@ XML_NS = [ def xmlToText(xElem): - """Get the text content of an XML element. - """ + """Get the text content of an XML element.""" rTxt = ET.tostring(xElem, encoding="utf-8", xml_declaration=False).decode() for nSpace in XML_NS: rTxt = rTxt.replace(nSpace, "") @@ -53,8 +52,7 @@ def xmlToText(xElem): @pytest.mark.core def testCoreToOdt_Init(mockGUI): - """Test initialisation of the ODT document. - """ + """Test initialisation of the ODT document.""" theProject = NWProject(mockGUI) # Flat Doc @@ -109,8 +107,7 @@ def testCoreToOdt_Init(mockGUI): @pytest.mark.core def testCoreToOdt_TextFormatting(mockGUI): - """Test formatting of paragraphs. - """ + """Test formatting of paragraphs.""" theProject = NWProject(mockGUI) theDoc = ToOdt(theProject, isFlat=True) @@ -156,7 +153,7 @@ def testCoreToOdt_TextFormatting(mockGUI): # No Format theDoc.initDocument() theDoc._addTextPar("Standard", oStyle, "Hello World") - assert theDoc.getErrors() == [] + assert theDoc.errData == [] assert xmlToText(theDoc._xText) == ( "" "Hello World" @@ -166,7 +163,7 @@ def testCoreToOdt_TextFormatting(mockGUI): # Heading Level None theDoc.initDocument() theDoc._addTextPar("Standard", oStyle, "Hello World", isHead=True) - assert theDoc.getErrors() == [] + assert theDoc.errData == [] assert xmlToText(theDoc._xText) == ( "" "Hello World" @@ -176,7 +173,7 @@ def testCoreToOdt_TextFormatting(mockGUI): # Heading Level 1 theDoc.initDocument() theDoc._addTextPar("Standard", oStyle, "Hello World", isHead=True, oLevel="1") - assert theDoc.getErrors() == [] + assert theDoc.errData == [] assert xmlToText(theDoc._xText) == ( "" "Hello World" @@ -187,8 +184,8 @@ def testCoreToOdt_TextFormatting(mockGUI): theDoc.initDocument() theTxt = "A **few** _words_ from ~~our~~ sponsor" theFmt = " _B b_ I i _S s_ " - theDoc._addTextPar("Standard", oStyle, theTxt, theFmt=theFmt) - assert theDoc.getErrors() == [] + theDoc._addTextPar("Standard", oStyle, theTxt, tFmt=theFmt) + assert theDoc.errData == [] assert xmlToText(theDoc._xText) == ( "" "A few " @@ -201,8 +198,8 @@ def testCoreToOdt_TextFormatting(mockGUI): theDoc.initDocument() theTxt = "A **few** _wordsXXX" theFmt = " _b b_ I XXX" - theDoc._addTextPar("Standard", oStyle, theTxt, theFmt=theFmt) - assert theDoc.getErrors() == ["Unknown format tag encountered"] + theDoc._addTextPar("Standard", oStyle, theTxt, tFmt=theFmt) + assert theDoc.errData == ["Unknown format tag encountered"] assert xmlToText(theDoc._xText) == ( "" "" @@ -215,8 +212,8 @@ def testCoreToOdt_TextFormatting(mockGUI): theDoc.initDocument() theTxt = "Hello\n\tWorld" theFmt = " " - theDoc._addTextPar("Standard", oStyle, theTxt, theFmt=theFmt) - assert theDoc.getErrors() == [] + theDoc._addTextPar("Standard", oStyle, theTxt, tFmt=theFmt) + assert theDoc.errData == [] assert xmlToText(theDoc._xText) == ( "" "HelloWorld" @@ -230,8 +227,8 @@ def testCoreToOdt_TextFormatting(mockGUI): theDoc.initDocument() theTxt = "Test text \\**_bold_** and more." theFmt = " I i " - theDoc._addTextPar("Standard", oStyle, theTxt, theFmt=theFmt) - assert theDoc.getErrors() == [] + theDoc._addTextPar("Standard", oStyle, theTxt, tFmt=theFmt) + assert theDoc.errData == [] assert xmlToText(theDoc._xText) == ( "" "Test text **" @@ -244,8 +241,7 @@ def testCoreToOdt_TextFormatting(mockGUI): @pytest.mark.core def testCoreToOdt_Convert(mockGUI): - """Test the converter of the ToOdt class. - """ + """Test the converter of the ToOdt class.""" theProject = NWProject(mockGUI) theDoc = ToOdt(theProject, isFlat=True) @@ -266,7 +262,7 @@ def testCoreToOdt_Convert(mockGUI): theDoc.initDocument() theDoc.doConvert() theDoc.closeDocument() - assert theDoc.getErrors() == [] + assert theDoc.errData == [] assert xmlToText(theDoc._xText) == ( '' 'Title' @@ -279,7 +275,7 @@ def testCoreToOdt_Convert(mockGUI): theDoc.initDocument() theDoc.doConvert() theDoc.closeDocument() - assert theDoc.getErrors() == [] + assert theDoc.errData == [] assert xmlToText(theDoc._xText) == ( '' 'Chapter' @@ -292,7 +288,7 @@ def testCoreToOdt_Convert(mockGUI): theDoc.initDocument() theDoc.doConvert() theDoc.closeDocument() - assert theDoc.getErrors() == [] + assert theDoc.errData == [] assert xmlToText(theDoc._xText) == ( '' 'Scene' @@ -305,7 +301,7 @@ def testCoreToOdt_Convert(mockGUI): theDoc.initDocument() theDoc.doConvert() theDoc.closeDocument() - assert theDoc.getErrors() == [] + assert theDoc.errData == [] assert xmlToText(theDoc._xText) == ( '' 'Section' @@ -318,7 +314,7 @@ def testCoreToOdt_Convert(mockGUI): theDoc.initDocument() theDoc.doConvert() theDoc.closeDocument() - assert theDoc.getErrors() == [] + assert theDoc.errData == [] assert xmlToText(theDoc._xText) == ( '' 'Title' @@ -331,7 +327,7 @@ def testCoreToOdt_Convert(mockGUI): theDoc.initDocument() theDoc.doConvert() theDoc.closeDocument() - assert theDoc.getErrors() == [] + assert theDoc.errData == [] assert xmlToText(theDoc._xText) == ( '' 'Prologue' @@ -347,7 +343,7 @@ def testCoreToOdt_Convert(mockGUI): theDoc.initDocument() theDoc.doConvert() theDoc.closeDocument() - assert theDoc.getErrors() == [] + assert theDoc.errData == [] assert xmlToText(theDoc._xText) == ( '' 'Some ' @@ -365,7 +361,7 @@ def testCoreToOdt_Convert(mockGUI): theDoc.initDocument() theDoc.doConvert() theDoc.closeDocument() - assert theDoc.getErrors() == [] + assert theDoc.errData == [] assert xmlToText(theDoc._xText) == ( '' 'Some text.Next line' @@ -378,7 +374,7 @@ def testCoreToOdt_Convert(mockGUI): theDoc.initDocument() theDoc.doConvert() theDoc.closeDocument() - assert theDoc.getErrors() == [] + assert theDoc.errData == [] assert xmlToText(theDoc._xText) == ( '' 'Item 1Item 2' @@ -391,7 +387,7 @@ def testCoreToOdt_Convert(mockGUI): theDoc.initDocument() theDoc.doConvert() theDoc.closeDocument() - assert theDoc.getErrors() == [] + assert theDoc.errData == [] assert xmlToText(theDoc._xText) == ( '' 'Some ' @@ -410,7 +406,7 @@ def testCoreToOdt_Convert(mockGUI): theDoc.initDocument() theDoc.doConvert() theDoc.closeDocument() - assert theDoc.getErrors() == [] + assert theDoc.errData == [] assert xmlToText(theDoc._xText) == ( '' 'Scene' @@ -434,7 +430,7 @@ def testCoreToOdt_Convert(mockGUI): theDoc.initDocument() theDoc.doConvert() theDoc.closeDocument() - assert theDoc.getErrors() == [] + assert theDoc.errData == [] assert xmlToText(theDoc._xText) == ( '' 'Scene' @@ -455,7 +451,7 @@ def testCoreToOdt_Convert(mockGUI): theDoc.initDocument() theDoc.doConvert() theDoc.closeDocument() - assert theDoc.getErrors() == [] + assert theDoc.errData == [] assert xmlToText(theDoc._xText) == ( '' '* * *' @@ -473,7 +469,7 @@ def testCoreToOdt_Convert(mockGUI): theDoc.initDocument() theDoc.doConvert() theDoc.closeDocument() - assert theDoc.getErrors() == [] + assert theDoc.errData == [] assert xmlToText(theDoc._xText) == ( '' '' @@ -500,7 +496,7 @@ def testCoreToOdt_Convert(mockGUI): theDoc.initDocument() theDoc.doConvert() theDoc.closeDocument() - assert theDoc.getErrors() == [] + assert theDoc.errData == [] assert xmlToText(theDoc._xText) == ( '' 'Scene' @@ -537,7 +533,7 @@ def testCoreToOdt_Convert(mockGUI): theDoc.initDocument() theDoc.doConvert() theDoc.closeDocument() - assert theDoc.getErrors() == [] + assert theDoc.errData == [] assert xmlToText(theDoc._xText) == ( '' 'Scene' @@ -559,7 +555,7 @@ def testCoreToOdt_Convert(mockGUI): theDoc.initDocument() theDoc.doConvert() theDoc.closeDocument() - assert theDoc.getErrors() == [] + assert theDoc.errData == [] assert xmlToText(theDoc._xText) == ( '' 'Chapter One' @@ -629,8 +625,7 @@ def testCoreToOdt_ConvertDirect(mockGUI): @pytest.mark.core def testCoreToOdt_SaveFlat(mockGUI, fncPath, tstPaths): - """Test the document save functions. - """ + """Test the document save functions.""" theProject = NWProject(mockGUI) theProject.data.setAuthor("Jane Smith") theProject.data.setName("Test Project") @@ -639,8 +634,11 @@ def testCoreToOdt_SaveFlat(mockGUI, fncPath, tstPaths): theDoc = ToOdt(theProject, isFlat=True) theDoc._isNovel = True - assert theDoc.setLanguage(None) is False - assert theDoc.setLanguage("nb_NO") is True + theDoc._dLanguage = "" + theDoc.setLanguage(None) + assert theDoc._dLanguage == "" + theDoc.setLanguage("nb_NO") + assert theDoc._dLanguage == "nb" theDoc.setColourHeaders(True) theDoc._text = ( @@ -669,8 +667,7 @@ def testCoreToOdt_SaveFlat(mockGUI, fncPath, tstPaths): @pytest.mark.core def testCoreToOdt_SaveFull(mockGUI, fncPath, tstPaths): - """Test the document save functions. - """ + """Test the document save functions.""" theProject = NWProject(mockGUI) theProject.data.setAuthor("Jane Smith") theProject.data.setName("Test Project") @@ -747,8 +744,7 @@ def testCoreToOdt_SaveFull(mockGUI, fncPath, tstPaths): @pytest.mark.core def testCoreToOdt_Format(mockGUI): - """Test the formatters for the ToOdt class. - """ + """Test the formatters for the ToOdt class.""" theProject = NWProject(mockGUI) theDoc = ToOdt(theProject, isFlat=True) @@ -761,7 +757,7 @@ def testCoreToOdt_Format(mockGUI): "_B b_ " ) - assert theDoc._formatKeywords("") == "" + assert theDoc._formatKeywords("") == ("", "") assert theDoc._formatKeywords("tag: Jane") == ( "**Tag:** Jane", "_B b_ " @@ -776,8 +772,7 @@ def testCoreToOdt_Format(mockGUI): @pytest.mark.core def testCoreToOdt_ODTParagraphStyle(): - """Test the ODTParagraphStyle class. - """ + """Test the ODTParagraphStyle class.""" parStyle = ODTParagraphStyle() # Set Attributes @@ -992,8 +987,7 @@ def testCoreToOdt_ODTParagraphStyle(): @pytest.mark.core def testCoreToOdt_ODTTextStyle(): - """Test the ODTTextStyle class. - """ + """Test the ODTTextStyle class.""" txtStyle = ODTTextStyle() # Font Weight @@ -1065,8 +1059,7 @@ def testCoreToOdt_ODTTextStyle(): @pytest.mark.core def testCoreToOdt_XMLParagraph(): - """Test XML encoding of paragraph. - """ + """Test XML encoding of paragraph.""" # Stage 1 : Text # ============== @@ -1257,8 +1250,7 @@ def testCoreToOdt_XMLParagraph(): @pytest.mark.core def testCoreToOdt_MkTag(): - """Test the tag maker function. - """ + """Test the tag maker function.""" assert _mkTag("office", "text") == "{urn:oasis:names:tc:opendocument:xmlns:office:1.0}text" assert _mkTag("style", "text") == "{urn:oasis:names:tc:opendocument:xmlns:style:1.0}text" assert _mkTag("blabla", "text") == "text" From 3bcb4a509104b5ec88feb2de4baa1bf2c448dd7e Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 6 Jun 2023 22:47:37 +0200 Subject: [PATCH 67/74] Add back the file name label on manuscript build dialog --- novelwriter/tools/manusbuild.py | 58 ++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/novelwriter/tools/manusbuild.py b/novelwriter/tools/manusbuild.py index 7412658a..7468fe51 100644 --- a/novelwriter/tools/manusbuild.py +++ b/novelwriter/tools/manusbuild.py @@ -31,8 +31,8 @@ from typing import TYPE_CHECKING from PyQt5.QtCore import QSize, Qt, pyqtSlot from PyQt5.QtWidgets import ( QAbstractButton, QAbstractItemView, QDialog, QDialogButtonBox, QFileDialog, - QLabel, QListWidget, QListWidgetItem, QProgressBar, QPushButton, QSplitter, - QVBoxLayout, QWidget + QGridLayout, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, + QProgressBar, QPushButton, QSplitter, QVBoxLayout, QWidget ) from novelwriter import CONFIG @@ -140,8 +140,18 @@ class GuiManuscriptBuild(QDialog): self.lblMain.setWordWrap(True) self.lblMain.setFont(font) + # Build Name + self.lblName = QLabel(self.tr("File Name")) + self.buildName = QLineEdit() + self.btnReset = QPushButton(self.mainTheme.getIcon("revert"), "") + self.btnReset.setToolTip(self.tr("Reset file name to default")) + + self.nameBox = QHBoxLayout() + self.nameBox.addWidget(self.buildName) + self.nameBox.addWidget(self.btnReset) + # Build Progress - self.lblProgress = QLabel(self.tr("Build Progress")) + self.lblProgress = QLabel(self.tr("Progress")) self.buildProgress = QProgressBar() self.buildProgress.setMinimum(0) @@ -152,11 +162,18 @@ class GuiManuscriptBuild(QDialog): self.progressBox.addWidget(self.buildProgress) self.progressBox.setSpacing(CONFIG.pxInt(4)) + # Build Box + self.buildBox = QGridLayout() + self.buildBox.addWidget(self.lblName, 1, 0) + self.buildBox.addLayout(self.nameBox, 1, 1) + self.buildBox.addWidget(self.lblProgress, 2, 0) + self.buildBox.addWidget(self.buildProgress, 2, 1) + self.buildBox.setVerticalSpacing(CONFIG.pxInt(4)) + # Dialog Buttons self.btnBuild = QPushButton(self.mainTheme.getIcon("export"), self.tr("&Build")) self.dlgButtons = QDialogButtonBox(QDialogButtonBox.Close) self.dlgButtons.addButton(self.btnBuild, QDialogButtonBox.ActionRole) - self.dlgButtons.clicked.connect(self._dialogButtonClicked) # Assemble GUI # ============ @@ -175,7 +192,7 @@ class GuiManuscriptBuild(QDialog): self.outerBox = QVBoxLayout() self.outerBox.addWidget(self.lblMain, 0, Qt.AlignCenter) self.outerBox.addWidget(self.mainSplit, 1) - self.outerBox.addLayout(self.progressBox, 0) + self.outerBox.addLayout(self.buildBox, 0) self.outerBox.addWidget(self.dlgButtons, 0) self.outerBox.setSpacing(CONFIG.pxInt(12)) @@ -183,6 +200,15 @@ class GuiManuscriptBuild(QDialog): self.btnBuild.setFocus() self._populateContentList() + if self._build.lastBuildName: + self.buildName.setText(self._build.lastBuildName) + else: + self._doResetBuildName() + + # Signals + self.btnReset.clicked.connect(self._doResetBuildName) + self.dlgButtons.clicked.connect(self._dialogButtonClicked) + self.listFormats.itemSelectionChanged.connect(self._formatSelectionChanged) logger.debug("Ready: GuiManuscriptBuild") @@ -226,6 +252,22 @@ class GuiManuscriptBuild(QDialog): self.close() return + @pyqtSlot() + def _doResetBuildName(self): + """Generate a default build name.""" + bName = f"{self.theProject.data.name} - {self._build.name}" + self.buildName.setText(bName) + self._build.setLastBuildName(bName) + return + + @pyqtSlot() + def _formatSelectionChanged(self): + """The user selected a different format, so we reset the + progress bar. + """ + self.buildProgress.setValue(0) + return + ## # Internal Functions ## @@ -236,9 +278,9 @@ class GuiManuscriptBuild(QDialog): if not selFormat or selFormat not in nwLabels.BUILD_FORMATS: return False - lastName = self._build.lastBuildName + lastName = self.buildName.text().strip() if not lastName: - lastName = f"{self.theProject.data.name} - {self._build.name}.ext" + self._doResetBuildName() lastPath = self._build.lastPath selExt = nwLabels.BUILD_FORMATS[selFormat][0] @@ -262,7 +304,7 @@ class GuiManuscriptBuild(QDialog): self._build.setLastFormat(selFormat) self._build.setLastPath(buildPath.parent) - self._build.setLastBuildName(buildPath.name) + self._build.setLastBuildName(lastName) return True From 4ee452d186f0f12a268b0383fc1262263d7deaf6 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 7 Jun 2023 22:37:47 +0200 Subject: [PATCH 68/74] Add a circular progress bar to the manuscript build tool --- novelwriter/extensions/circularprogress.py | 62 ++++++++++++++++++++++ novelwriter/tools/manuscript.py | 36 ++++++++----- 2 files changed, 85 insertions(+), 13 deletions(-) create mode 100644 novelwriter/extensions/circularprogress.py diff --git a/novelwriter/extensions/circularprogress.py b/novelwriter/extensions/circularprogress.py new file mode 100644 index 00000000..af465aaf --- /dev/null +++ b/novelwriter/extensions/circularprogress.py @@ -0,0 +1,62 @@ +""" +novelWriter – Custom Widget: ProgressCircle +=========================================== + +File History: +Created: 2023-06-07 [2.1b1] + +This file is a part of novelWriter +Copyright 2018–2023, 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 . +""" + +from math import ceil + +from PyQt5.QtGui import QPaintEvent, QPainter, QPen +from PyQt5.QtCore import QRect, Qt +from PyQt5.QtWidgets import QProgressBar, QSizePolicy, QWidget + + +class NProgressCircle(QProgressBar): + + __slots__ = ("_mPx", "_bPen", "_cPen", "_cRect", "_tColor") + + def __init__(self, parent: QWidget, size: int, point: int): + super().__init__(parent=parent) + self._mPx = point + self._bPen = QPen(self.palette().alternateBase(), point, Qt.SolidLine, Qt.RoundCap) + self._cPen = QPen(self.palette().highlight(), point, Qt.SolidLine, Qt.RoundCap) + self._cRect = QRect(point, point, size - 2*point, size - 2*point) + self._tColor = self.palette().text().color() + self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + self.setFixedWidth(size) + self.setFixedHeight(size) + return + + def paintEvent(self, event: QPaintEvent): + """Custom painter for the progress bar.""" + progress = 100.0*self.value()/self.maximum() + angle = ceil(16*3.6*progress) + qPaint = QPainter(self) + qPaint.setRenderHint(QPainter.Antialiasing, True) + qPaint.setPen(self._bPen) + qPaint.drawArc(self._cRect, 0, 360*16) + qPaint.setPen(self._cPen) + qPaint.drawArc(self._cRect, 90*16, -angle) + qPaint.setPen(self._tColor) + qPaint.drawText(self._cRect, Qt.AlignCenter, f"{progress:.1f} %") + return + +# END Class NProgressCircle diff --git a/novelwriter/tools/manuscript.py b/novelwriter/tools/manuscript.py index 51bd3121..f10cd81d 100644 --- a/novelwriter/tools/manuscript.py +++ b/novelwriter/tools/manuscript.py @@ -34,7 +34,7 @@ from PyQt5.QtGui import QColor, QCursor, QFont, QPalette, QResizeEvent from PyQt5.QtCore import QSize, QTimer, Qt, pyqtSlot from PyQt5.QtWidgets import ( QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QMenu, - QProgressBar, QPushButton, QSplitter, QTextBrowser, QToolButton, QVBoxLayout, QWidget, + QPushButton, QSplitter, QTextBrowser, QToolButton, QVBoxLayout, QWidget, qApp ) @@ -46,6 +46,7 @@ from novelwriter.core.docbuild import NWBuildDocument from novelwriter.core.buildsettings import BuildCollection, BuildSettings from novelwriter.tools.manusbuild import GuiManuscriptBuild from novelwriter.tools.manussettings import GuiBuildSettings +from novelwriter.extensions.circularprogress import NProgressCircle if TYPE_CHECKING: # pragma: no cover from novelwriter.guimain import GuiMain @@ -143,14 +144,12 @@ class GuiManuscript(QDialog): # Process Controls # ================ - self.buildProgress = QProgressBar() + self.buildProgress = NProgressCircle(self, CONFIG.pxInt(140), CONFIG.pxInt(16)) self.buildProgress.setValue(0) - self.btnPreview = QPushButton(self.tr("Build Preview")) + self.btnPreview = QPushButton(self.tr("Preview")) self.btnPreview.clicked.connect(self._generatePreview) - self.docPreview = _PreviewWidget(self.mainGui) - self.btnBuild = QPushButton(self.tr("Build")) self.btnBuild.clicked.connect(self._buildManuscript) @@ -164,20 +163,28 @@ class GuiManuscript(QDialog): self.btnClose = QPushButton(self.tr("Close")) self.btnClose.clicked.connect(self._doClose) + self.buildBox = QVBoxLayout() + self.buildBox.addStretch(1) + self.buildBox.addWidget(self.btnPreview, 0) + self.buildBox.addWidget(self.btnPrint, 0) + self.buildBox.addWidget(self.btnBuild, 0) + self.buildBox.addWidget(self.btnClose, 0) + self.buildBox.addStretch(1) + self.buildBox.setSpacing(CONFIG.pxInt(2)) + self.processBox = QHBoxLayout() - self.processBox.addWidget(self.btnBuild) - self.processBox.addWidget(self.btnPrint) - self.processBox.addWidget(self.btnClose) + self.processBox.addWidget(self.buildProgress) + self.processBox.addLayout(self.buildBox) # Assemble GUI # ============ + self.docPreview = _PreviewWidget(self.mainGui) + self.controlBox = QVBoxLayout() - self.controlBox.addLayout(self.listToolBox) - self.controlBox.addWidget(self.buildList) - self.controlBox.addWidget(self.buildProgress) - self.controlBox.addWidget(self.btnPreview) - self.controlBox.addLayout(self.processBox) + self.controlBox.addLayout(self.listToolBox, 0) + self.controlBox.addWidget(self.buildList, 1) + self.controlBox.addLayout(self.processBox, 0) self.controlBox.setContentsMargins(0, 0, 0, 0) self.optsWidget = QWidget() @@ -186,6 +193,8 @@ class GuiManuscript(QDialog): self.mainSplit = QSplitter() self.mainSplit.addWidget(self.optsWidget) self.mainSplit.addWidget(self.docPreview) + self.mainSplit.setStretchFactor(0, 0) + self.mainSplit.setStretchFactor(1, 1) self.mainSplit.setSizes([ CONFIG.pxInt(pOptions.getInt("GuiManuscript", "optsWidth", wWin//3)), CONFIG.pxInt(pOptions.getInt("GuiManuscript", "viewWidth", 2*wWin//3)), @@ -288,6 +297,7 @@ class GuiManuscript(QDialog): self.buildProgress.setMaximum(len(docBuild)) for step, status in docBuild.iterBuildHTML(None): self.buildProgress.setValue(step + 1) + qApp.processEvents() buildObj = docBuild.lastBuild assert isinstance(buildObj, ToHtml) From 8ef1a12201ff0ae74e0a891eb7894bacab3fda31 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 8 Jun 2023 23:32:32 +0200 Subject: [PATCH 69/74] Use enums for build formats --- novelwriter/constants.py | 30 ++++++++++++++++++++---------- novelwriter/core/buildsettings.py | 23 ++++++++++++++--------- novelwriter/core/docbuild.py | 11 ++++++----- novelwriter/enum.py | 14 ++++++++++++++ novelwriter/tools/manusbuild.py | 15 ++++++++------- 5 files changed, 62 insertions(+), 31 deletions(-) diff --git a/novelwriter/constants.py b/novelwriter/constants.py index 48d10f51..dba2d2d0 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -25,7 +25,7 @@ along with this program. If not, see . from PyQt5.QtCore import QCoreApplication, QT_TRANSLATE_NOOP -from novelwriter.enum import nwItemClass, nwItemLayout, nwOutline +from novelwriter.enum import nwBuildFmt, nwItemClass, nwItemLayout, nwOutline def trConst(tString): @@ -205,15 +205,25 @@ class nwLabels: nwOutline.CUSTOM: KEY_NAME[nwKeyWords.CUSTOM_KEY], nwOutline.SYNOP: QT_TRANSLATE_NOOP("Constant", "Synopsis"), } - BUILD_FORMATS = { - "odt": (".odt", QT_TRANSLATE_NOOP("Constant", "Open Document (.odt)")), - "fodt": (".fodt", QT_TRANSLATE_NOOP("Constant", "Flat Open Document (.fodt)")), - "html": (".html", QT_TRANSLATE_NOOP("Constant", "novelWriter HTML (.html)")), - "nwd": (".nwd", QT_TRANSLATE_NOOP("Constant", "novelWriter Markdown (.nwd)")), - "md": (".md", QT_TRANSLATE_NOOP("Constant", "Standard Markdown (.md)")), - "md+": (".md", QT_TRANSLATE_NOOP("Constant", "Extended Markdown (.md)")), - "jhtml": (".json", QT_TRANSLATE_NOOP("Constant", "JSON + novelWriter HTML (.json)")), - "jnwd": (".json", QT_TRANSLATE_NOOP("Constant", "JSON + novelWriter Markdown (.json)")), + BUILD_FMT = { + nwBuildFmt.ODT: QT_TRANSLATE_NOOP("Constant", "Open Document (.odt)"), + nwBuildFmt.FODT: QT_TRANSLATE_NOOP("Constant", "Flat Open Document (.fodt)"), + nwBuildFmt.HTML: QT_TRANSLATE_NOOP("Constant", "novelWriter HTML (.html)"), + nwBuildFmt.NWD: QT_TRANSLATE_NOOP("Constant", "novelWriter Markup (.txt)"), + nwBuildFmt.STD_MD: QT_TRANSLATE_NOOP("Constant", "Standard Markdown (.md)"), + nwBuildFmt.EXT_MD: QT_TRANSLATE_NOOP("Constant", "Extended Markdown (.md)"), + nwBuildFmt.J_HTML: QT_TRANSLATE_NOOP("Constant", "JSON + novelWriter HTML (.json)"), + nwBuildFmt.J_NWD: QT_TRANSLATE_NOOP("Constant", "JSON + novelWriter Markup (.json)"), + } + BUILD_EXT = { + nwBuildFmt.ODT: ".odt", + nwBuildFmt.FODT: ".fodt", + nwBuildFmt.HTML: ".html", + nwBuildFmt.NWD: ".txt", + nwBuildFmt.STD_MD: ".md", + nwBuildFmt.EXT_MD: ".md", + nwBuildFmt.J_HTML: ".json", + nwBuildFmt.J_NWD: ".json", } # END Class nwLabels diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index 1d6606ee..6f12472b 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -34,11 +34,12 @@ from pathlib import Path from PyQt5.QtCore import QT_TRANSLATE_NOOP +from novelwriter.enum import nwBuildFmt +from novelwriter.error import logException from novelwriter.common import checkUuid, isHandle, jsonEncode -from novelwriter.constants import nwFiles, nwHeadFmt, nwLabels +from novelwriter.constants import nwFiles, nwHeadFmt from novelwriter.core.item import NWItem from novelwriter.core.project import NWProject -from novelwriter.error import logException logger = logging.getLogger(__name__) @@ -140,7 +141,7 @@ class BuildSettings: self._uuid = str(uuid.uuid4()) self._path = Path.home() self._build = "" - self._format = "odt" + self._format = nwBuildFmt.ODT self._skipRoot = set() self._excluded = set() self._included = set() @@ -175,7 +176,7 @@ class BuildSettings: return self._build @property - def lastFormat(self) -> str: + def lastFormat(self) -> nwBuildFmt: """The last used build format.""" return self._format @@ -252,10 +253,10 @@ class BuildSettings: self._changed = True return - def setLastFormat(self, key: str): + def setLastFormat(self, value: nwBuildFmt): """Set the last used build format.""" - if key in nwLabels.BUILD_FORMATS: - self._format = key + if isinstance(value, nwBuildFmt): + self._format = value self._changed = True return @@ -377,7 +378,7 @@ class BuildSettings: "uuid": self._uuid, "path": str(self._path), "build": self._build, - "format": self._format, + "format": self._format.name, "settings": self._settings.copy(), "content": { "included": list(self._included), @@ -398,7 +399,11 @@ class BuildSettings: self.setBuildID(data.get("uuid", "")) self.setLastPath(data.get("path", None)) self.setLastBuildName(data.get("build", "")) - self.setLastFormat(data.get("format", "odt")) + + buildFmt = str(data.get("build", "")) + if buildFmt in nwBuildFmt.__members__: + self.setLastFormat(nwBuildFmt[buildFmt]) + if isinstance(included, list): self._included = set([h for h in included if isHandle(h)]) if isinstance(excluded, list): diff --git a/novelwriter/core/docbuild.py b/novelwriter/core/docbuild.py index 125d47c5..de3ab546 100644 --- a/novelwriter/core/docbuild.py +++ b/novelwriter/core/docbuild.py @@ -31,6 +31,7 @@ from pathlib import Path from PyQt5.QtGui import QFont, QFontInfo from novelwriter import CONFIG +from novelwriter.enum import nwBuildFmt from novelwriter.error import formatException from novelwriter.core.tomd import ToMarkdown from novelwriter.core.toodt import ToOdt @@ -106,15 +107,15 @@ class NWBuildDocument: self._queue.append(item.itemHandle) return - def iterBuild(self, path: Path, bFormat: str) -> Iterable[tuple[int, bool]]: + def iterBuild(self, path: Path, bFormat: nwBuildFmt) -> Iterable[tuple[int, bool]]: """Wrapper for builders based on format.""" - if bFormat in ("odt", "fodt"): + if bFormat in (nwBuildFmt.ODT, nwBuildFmt.FODT): yield from self.iterBuildOpenDocument(path, bFormat == "fodt") - elif bFormat in ("html", "jhtml"): + elif bFormat in (nwBuildFmt.HTML, nwBuildFmt.J_HTML): yield from self.iterBuildHTML(path if bFormat == "html" else None) - elif bFormat in ("md", "md+"): + elif bFormat in (nwBuildFmt.STD_MD, nwBuildFmt.EXT_MD): yield from self.iterBuildMarkdown(path, bFormat == "md+") - elif bFormat in ("nwd", "jnwd"): + elif bFormat in (nwBuildFmt.NWD, nwBuildFmt.J_NWD): yield from self.iterBuildNovelWriter(path if bFormat == "nwd" else None) return diff --git a/novelwriter/enum.py b/novelwriter/enum.py index d2fcf61b..d96e4b2f 100644 --- a/novelwriter/enum.py +++ b/novelwriter/enum.py @@ -171,3 +171,17 @@ class nwOutline(Enum): SYNOP = 16 # END Enum nwOutline + + +class nwBuildFmt(Enum): + + ODT = 0 + FODT = 1 + HTML = 2 + NWD = 3 + STD_MD = 4 + EXT_MD = 5 + J_HTML = 6 + J_NWD = 7 + +# END Enum nwBuildFormat diff --git a/novelwriter/tools/manusbuild.py b/novelwriter/tools/manusbuild.py index 7468fe51..f00f3bd0 100644 --- a/novelwriter/tools/manusbuild.py +++ b/novelwriter/tools/manusbuild.py @@ -36,10 +36,11 @@ from PyQt5.QtWidgets import ( ) from novelwriter import CONFIG +from novelwriter.enum import nwBuildFmt from novelwriter.common import makeFileNameSafe from novelwriter.constants import nwLabels -from novelwriter.core.docbuild import NWBuildDocument from novelwriter.core.item import NWItem +from novelwriter.core.docbuild import NWBuildDocument from novelwriter.core.buildsettings import BuildSettings if TYPE_CHECKING: @@ -91,9 +92,9 @@ class GuiManuscriptBuild(QDialog): self.listFormats = QListWidget() self.listFormats.setIconSize(QSize(iPx, iPx)) current = None - for key, (_, label) in nwLabels.BUILD_FORMATS.items(): + for key in nwBuildFmt: item = QListWidgetItem() - item.setText(label) + item.setText(nwLabels.BUILD_FMT[key]) item.setData(self.D_KEY, key) self.listFormats.addItem(item) if key == self._build.lastFormat: @@ -275,7 +276,7 @@ class GuiManuscriptBuild(QDialog): def _runBuild(self) -> bool: """Run the currently selected build.""" selFormat = self._getSelectedFormat() - if not selFormat or selFormat not in nwLabels.BUILD_FORMATS: + if not isinstance(selFormat, nwBuildFmt): return False lastName = self.buildName.text().strip() @@ -283,7 +284,7 @@ class GuiManuscriptBuild(QDialog): self._doResetBuildName() lastPath = self._build.lastPath - selExt = nwLabels.BUILD_FORMATS[selFormat][0] + selExt = nwLabels.BUILD_EXT[selFormat] selName = Path(makeFileNameSafe(lastName)).with_suffix(selExt) self.buildProgress.setValue(0) @@ -308,11 +309,11 @@ class GuiManuscriptBuild(QDialog): return True - def _getSelectedFormat(self) -> str | None: + def _getSelectedFormat(self) -> nwBuildFmt | None: """Get the currently selected format.""" items = self.listFormats.selectedItems() if items and isinstance(items[0], QListWidgetItem): - return str(items[0].data(self.D_KEY)) + return items[0].data(self.D_KEY) return None def _saveSettings(self): From 8b7aab36a27300744298ddcea802eb560605cff2 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 8 Jun 2023 23:33:16 +0200 Subject: [PATCH 70/74] Add circular progress bar on manuscript preview --- novelwriter/extensions/circularprogress.py | 57 +++++++++++++--- novelwriter/tools/manuscript.py | 75 ++++++++++++++++------ 2 files changed, 105 insertions(+), 27 deletions(-) diff --git a/novelwriter/extensions/circularprogress.py b/novelwriter/extensions/circularprogress.py index af465aaf..dfaf586b 100644 --- a/novelwriter/extensions/circularprogress.py +++ b/novelwriter/extensions/circularprogress.py @@ -1,6 +1,6 @@ """ -novelWriter – Custom Widget: ProgressCircle -=========================================== +novelWriter – Custom Widget: Progress Circle +============================================ File History: Created: 2023-06-07 [2.1b1] @@ -24,39 +24,78 @@ along with this program. If not, see . from math import ceil -from PyQt5.QtGui import QPaintEvent, QPainter, QPen +from PyQt5.QtGui import QBrush, QColor, QPaintEvent, QPainter, QPen from PyQt5.QtCore import QRect, Qt from PyQt5.QtWidgets import QProgressBar, QSizePolicy, QWidget class NProgressCircle(QProgressBar): + """Extension: Circular Progress Widget - __slots__ = ("_mPx", "_bPen", "_cPen", "_cRect", "_tColor") + A custom widget that paints a circular progress indicator instead of + a straight bar. It is also possible to set custom text for iṫ. + """ + + __slots__ = ( + "_text", "_point", "_dRect", "_cRect", "_dPen", "_dBrush", + "_cPen", "_bPen", "_tColor" + ) def __init__(self, parent: QWidget, size: int, point: int): super().__init__(parent=parent) - self._mPx = point - self._bPen = QPen(self.palette().alternateBase(), point, Qt.SolidLine, Qt.RoundCap) - self._cPen = QPen(self.palette().highlight(), point, Qt.SolidLine, Qt.RoundCap) + self._text = None + self._point = point + self._dRect = QRect(0, 0, size, size) self._cRect = QRect(point, point, size - 2*point, size - 2*point) - self._tColor = self.palette().text().color() + self._dPen = QPen(Qt.transparent) + self._dBrush = QBrush(Qt.transparent) + self.setColours( + track=self.palette().alternateBase().color(), + bar=self.palette().highlight().color(), + text=self.palette().text().color() + ) self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) self.setFixedWidth(size) self.setFixedHeight(size) return + def setColours( + self, back: QColor | None = None, track: QColor | None = None, + bar: QColor | None = None, text: QColor | None = None + ): + """Set the colours of the widget.""" + if isinstance(back, QColor): + self._dPen = QPen(back) + self._dBrush = QBrush(back) + if isinstance(bar, QColor): + self._cPen = QPen(QBrush(bar), self._point, Qt.SolidLine, Qt.RoundCap) + if isinstance(track, QColor): + self._bPen = QPen(QBrush(track), self._point, Qt.SolidLine, Qt.RoundCap) + if isinstance(text, QColor): + self._tColor = text + return + + def setCentreText(self, text: str | None): + """Replace the progress text with a custom string.""" + self._text = text + self.setValue(self.value()) # Triggers a redraw + return + def paintEvent(self, event: QPaintEvent): """Custom painter for the progress bar.""" progress = 100.0*self.value()/self.maximum() angle = ceil(16*3.6*progress) qPaint = QPainter(self) qPaint.setRenderHint(QPainter.Antialiasing, True) + qPaint.setPen(self._dPen) + qPaint.setBrush(self._dBrush) + qPaint.drawEllipse(self._dRect) qPaint.setPen(self._bPen) qPaint.drawArc(self._cRect, 0, 360*16) qPaint.setPen(self._cPen) qPaint.drawArc(self._cRect, 90*16, -angle) qPaint.setPen(self._tColor) - qPaint.drawText(self._cRect, Qt.AlignCenter, f"{progress:.1f} %") + qPaint.drawText(self._cRect, Qt.AlignCenter, self._text or f"{progress:.1f} %") return # END Class NProgressCircle diff --git a/novelwriter/tools/manuscript.py b/novelwriter/tools/manuscript.py index f10cd81d..2f5632db 100644 --- a/novelwriter/tools/manuscript.py +++ b/novelwriter/tools/manuscript.py @@ -144,10 +144,7 @@ class GuiManuscript(QDialog): # Process Controls # ================ - self.buildProgress = NProgressCircle(self, CONFIG.pxInt(140), CONFIG.pxInt(16)) - self.buildProgress.setValue(0) - - self.btnPreview = QPushButton(self.tr("Preview")) + self.btnPreview = QPushButton(self.tr("Build Preview")) self.btnPreview.clicked.connect(self._generatePreview) self.btnBuild = QPushButton(self.tr("Build")) @@ -163,17 +160,13 @@ class GuiManuscript(QDialog): self.btnClose = QPushButton(self.tr("Close")) self.btnClose.clicked.connect(self._doClose) - self.buildBox = QVBoxLayout() - self.buildBox.addStretch(1) - self.buildBox.addWidget(self.btnPreview, 0) - self.buildBox.addWidget(self.btnPrint, 0) + self.buildBox = QHBoxLayout() self.buildBox.addWidget(self.btnBuild, 0) + self.buildBox.addWidget(self.btnPrint, 0) self.buildBox.addWidget(self.btnClose, 0) - self.buildBox.addStretch(1) - self.buildBox.setSpacing(CONFIG.pxInt(2)) - self.processBox = QHBoxLayout() - self.processBox.addWidget(self.buildProgress) + self.processBox = QVBoxLayout() + self.processBox.addWidget(self.btnPreview) self.processBox.addLayout(self.buildBox) # Assemble GUI @@ -294,10 +287,11 @@ class GuiManuscript(QDialog): docBuild = NWBuildDocument(self.theProject, build) docBuild.queueAll() - self.buildProgress.setMaximum(len(docBuild)) - for step, status in docBuild.iterBuildHTML(None): - self.buildProgress.setValue(step + 1) + self.docPreview.beginNewBuild(len(docBuild)) + for step, _ in docBuild.iterBuildHTML(None): + self.docPreview.buildStep(step + 1) qApp.processEvents() + qApp.thread().msleep(5) buildObj = docBuild.lastBuild assert isinstance(buildObj, ToHtml) @@ -457,6 +451,8 @@ class _PreviewWidget(QTextBrowser): "Press the \"Build Preview\" button to generate ..." )) + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn) + # Document Age aPalette = self.palette() aPalette.setColor(QPalette.Background, aPalette.toolTipBase().color()) @@ -473,6 +469,17 @@ class _PreviewWidget(QTextBrowser): self.ageLabel.setAlignment(Qt.AlignCenter) self.ageLabel.setFixedHeight(int(2.1*self.mainTheme.fontPixelSize)) + # Progress + self.buildProgress = NProgressCircle(self, CONFIG.pxInt(160), CONFIG.pxInt(16)) + self.buildProgress.setVisible(False) + self.buildProgress.setMaximum(1) + self.buildProgress.setValue(0) + self.buildProgress.setColours( + back=QColor(255, 255, 255, 224), + track=QColor(196, 196, 196, 128), + text=QColor(0, 0, 0) + ) + self._updateDocMargins() self._updateBuildAge() @@ -516,11 +523,30 @@ class _PreviewWidget(QTextBrowser): # Methods ## + def beginNewBuild(self, length: int): + """Clear the document and show the progress bar.""" + self.buildProgress.setMaximum(length) + self.buildProgress.setValue(0) + self.buildProgress.setCentreText(None) + self.buildProgress.setVisible(True) + self.setPlaceholderText("") + self.clear() + return + + def buildStep(self, value: int): + """Update the progress bar value.""" + self.buildProgress.setValue(value) + qApp.processEvents() + return + def setContent(self, data: dict): """Set the content of the preview widget.""" sPos = self.verticalScrollBar().value() qApp.setOverrideCursor(QCursor(Qt.WaitCursor)) + self.buildProgress.setCentreText(self.tr("Processing ...")) + qApp.processEvents() + styles = "\n".join(data.get("styles", [ "h1, h2 {color: rgb(66, 113, 174);}", "h3, h4 {color: rgb(50, 50, 50);}", @@ -544,9 +570,13 @@ class _PreviewWidget(QTextBrowser): self._updateBuildAge() # Since we change the content while it may still be rendering, we mark - # the document dirty again to make sure it's re-rendered properly. + # the document as dirty again to make sure it's re-rendered properly. self.document().markContentsDirty(0, self.document().characterCount()) + + self.buildProgress.setCentreText(self.tr("Done")) qApp.restoreOverrideCursor() + qApp.processEvents() + QTimer.singleShot(300, self._hideProgress) return @@ -580,6 +610,12 @@ class _PreviewWidget(QTextBrowser): self.ageLabel.setText(text) return + @pyqtSlot() + def _hideProgress(self): + """Clean up the build progress bar.""" + self.buildProgress.setVisible(False) + return + ## # Internal Functions ## @@ -591,10 +627,13 @@ class _PreviewWidget(QTextBrowser): vBar = self.verticalScrollBar() sW = vBar.width() if vBar.isVisible() else 0 tB = self.frameWidth() - tW = self.width() - 2*tB - sW + vW = self.width() - 2*tB - sW + vH = self.height() - 2*tB tH = self.ageLabel.height() - self.ageLabel.setGeometry(tB, tB, tW, tH) + pS = self.buildProgress.width() + self.ageLabel.setGeometry(tB, tB, vW, tH) self.setViewportMargins(0, tH, 0, 0) + self.buildProgress.move((vW-pS)//2, (vH-pS)//2) return # END Class _PreviewWidget From 5f4b6009ce1a88939520c0a3e50896944fb43a65 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Fri, 9 Jun 2023 21:07:46 +0200 Subject: [PATCH 71/74] Complete the manuscript build dialog, and the doc builder class --- novelwriter/core/docbuild.py | 28 ++-- novelwriter/core/tohtml.py | 53 +++++--- novelwriter/core/tokenizer.py | 41 ++++-- novelwriter/core/tomd.py | 1 + novelwriter/core/toodt.py | 3 + novelwriter/extensions/circularprogress.py | 1 + novelwriter/extensions/simpleprogress.py | 54 ++++++++ novelwriter/tools/manusbuild.py | 122 ++++++++++++------ .../mBuildDocBuild_HTML5_Lorem_Ipsum.htm | 1 - tests/test_core/test_core_tohtml.py | 3 +- 10 files changed, 225 insertions(+), 82 deletions(-) create mode 100644 novelwriter/extensions/simpleprogress.py diff --git a/novelwriter/core/docbuild.py b/novelwriter/core/docbuild.py index de3ab546..6601496f 100644 --- a/novelwriter/core/docbuild.py +++ b/novelwriter/core/docbuild.py @@ -32,7 +32,7 @@ from PyQt5.QtGui import QFont, QFontInfo from novelwriter import CONFIG from novelwriter.enum import nwBuildFmt -from novelwriter.error import formatException +from novelwriter.error import formatException, logException from novelwriter.core.tomd import ToMarkdown from novelwriter.core.toodt import ToOdt from novelwriter.core.tohtml import ToHtml @@ -110,13 +110,13 @@ class NWBuildDocument: def iterBuild(self, path: Path, bFormat: nwBuildFmt) -> Iterable[tuple[int, bool]]: """Wrapper for builders based on format.""" if bFormat in (nwBuildFmt.ODT, nwBuildFmt.FODT): - yield from self.iterBuildOpenDocument(path, bFormat == "fodt") + yield from self.iterBuildOpenDocument(path, bFormat == nwBuildFmt.FODT) elif bFormat in (nwBuildFmt.HTML, nwBuildFmt.J_HTML): - yield from self.iterBuildHTML(path if bFormat == "html" else None) + yield from self.iterBuildHTML(path, asJson=bFormat == nwBuildFmt.J_HTML) elif bFormat in (nwBuildFmt.STD_MD, nwBuildFmt.EXT_MD): - yield from self.iterBuildMarkdown(path, bFormat == "md+") + yield from self.iterBuildMarkdown(path, bFormat == nwBuildFmt.EXT_MD) elif bFormat in (nwBuildFmt.NWD, nwBuildFmt.J_NWD): - yield from self.iterBuildNovelWriter(path if bFormat == "nwd" else None) + yield from self.iterBuildNWD(path, asJson=bFormat == nwBuildFmt.J_NWD) return def iterBuildOpenDocument(self, path: Path, isFlat: bool) -> Iterable[tuple[int, bool]]: @@ -143,11 +143,12 @@ class NWBuildDocument: else: makeObj.saveOpenDocText(path) except Exception as exc: + logException() self._error = formatException(exc) return - def iterBuildHTML(self, path: Path | None) -> Iterable[tuple[int, bool]]: + def iterBuildHTML(self, path: Path | None, asJson: bool = False) -> Iterable[tuple[int, bool]]: """Build an HTML file. If path is None, no file is saved. This is used for generating build previews. """ @@ -169,8 +170,12 @@ class NWBuildDocument: if isinstance(path, Path): try: - makeObj.saveHTML5(path) + if asJson: + makeObj.saveHtmlJson(path) + else: + makeObj.saveHtml5(path) except Exception as exc: + logException() self._error = formatException(exc) return @@ -201,11 +206,12 @@ class NWBuildDocument: try: makeObj.saveMarkdown(path) except Exception as exc: + logException() self._error = formatException(exc) return - def iterBuildNovelWriter(self, path: Path | None) -> Iterable[tuple[int, bool]]: + def iterBuildNWD(self, path: Path | None, asJson: bool = False) -> Iterable[tuple[int, bool]]: """Build a novelWriter Markdown file.""" makeObj = ToMarkdown(self._project) filtered = self._setupBuild(makeObj) @@ -226,8 +232,12 @@ class NWBuildDocument: if isinstance(path, Path): try: - makeObj.saveRawMarkdown(path) + if asJson: + makeObj.saveRawMarkdownJSON(path) + else: + makeObj.saveRawMarkdown(path) except Exception as exc: + logException() self._error = formatException(exc) return diff --git a/novelwriter/core/tohtml.py b/novelwriter/core/tohtml.py index f02daafb..1441e1d3 100644 --- a/novelwriter/core/tohtml.py +++ b/novelwriter/core/tohtml.py @@ -23,11 +23,14 @@ along with this program. If not, see . """ from __future__ import annotations +import json import logging +from time import time from pathlib import Path from novelwriter import CONFIG +from novelwriter.common import formatTimeStamp from novelwriter.constants import nwKeyWords, nwLabels, nwHtmlUnicode from novelwriter.core.project import NWProject from novelwriter.core.tokenizer import Tokenizer, stripEscape @@ -296,38 +299,52 @@ class ToHtml(Tokenizer): return - def saveHTML5(self, savePath: str | Path): - """Save the data to an .html file. - """ - with open(savePath, mode="w", encoding="utf-8") as outFile: - theStyle = self.getStyleSheet() - theStyle.append("article {width: 800px; margin: 40px auto;}") - bodyText = "".join(self._fullHTML) - bodyText = bodyText.replace("\t", " ").rstrip() - - theHtml = ( + def saveHtml5(self, path: str | Path): + """Save the data to an HTML file.""" + with open(path, mode="w", encoding="utf-8") as fObj: + fObj.write(( "\n" "\n" "\n" "\n" - "{projTitle:s}\n" + "{title:s}\n" "\n" "\n" "\n" "
\n" - "{bodyText:s}\n" + "{body:s}\n" "
\n" "\n" "\n" ).format( - projTitle=self._project.data.name, - htmlStyle="\n".join(theStyle), - bodyText=bodyText, - ) - outFile.write(theHtml) + title=self._project.data.name, + style="\n".join(self.getStyleSheet()), + body=("".join(self._fullHTML)).replace("\t", " ").rstrip(), + )) + logger.info("Wrote file: %s", path) + return + def saveHtmlJson(self, path: str | Path): + """Save the data to a JSON file.""" + timeStamp = time() + data = { + "meta": { + "projectName": self._project.data.name, + "novelTitle": self._project.data.title, + "novelAuthor": self._project.data.author, + "buildTime": int(timeStamp), + "buildTimeStr": formatTimeStamp(timeStamp), + }, + "text": { + "css": self.getStyleSheet(), + "html": [page.rstrip("\n").split("\n") for page in self.fullHTML], + } + } + with open(path, mode="w", encoding="utf-8") as fObj: + json.dump(data, fObj, indent=2) + logger.info("Wrote file: %s", path) return def replaceTabs(self, nSpaces: int = 8, spaceChar: str = " "): diff --git a/novelwriter/core/tokenizer.py b/novelwriter/core/tokenizer.py index 62a6a71f..e1a9aea4 100644 --- a/novelwriter/core/tokenizer.py +++ b/novelwriter/core/tokenizer.py @@ -25,9 +25,11 @@ along with this program. If not, see . from __future__ import annotations import re +import json import logging from abc import ABC, abstractmethod +from time import time from pathlib import Path from operator import itemgetter from functools import partial @@ -35,7 +37,7 @@ from functools import partial from PyQt5.QtCore import QCoreApplication, QRegularExpression from novelwriter.enum import nwItemLayout, nwItemType -from novelwriter.common import numberToRoman, checkInt +from novelwriter.common import formatTimeStamp, numberToRoman, checkInt from novelwriter.constants import nwConst, nwHeadFmt, nwRegEx, nwUnicode from novelwriter.core.project import NWProject @@ -740,13 +742,32 @@ class Tokenizer(ABC): return True - def saveRawMarkdown(self, savePath: str | Path): - """Save the data to a plain text file.""" - with open(savePath, mode="w", encoding="utf-8") as outFile: + def saveRawMarkdown(self, path: str | Path): + """Save the raw text to a plain text file.""" + with open(path, mode="w", encoding="utf-8") as outFile: for nwdPage in self._allMarkdown: outFile.write(nwdPage) return + def saveRawMarkdownJSON(self, path: str | Path): + """Save the raw text to a JSON file.""" + timeStamp = time() + data = { + "meta": { + "projectName": self._project.data.name, + "novelTitle": self._project.data.title, + "novelAuthor": self._project.data.author, + "buildTime": int(timeStamp), + "buildTimeStr": formatTimeStamp(timeStamp), + }, + "text": { + "nwd": [page.rstrip("\n").split("\n") for page in self._allMarkdown], + } + } + with open(path, mode="w", encoding="utf-8") as fObj: + json.dump(data, fObj, indent=2) + return + # END Class Tokenizer @@ -760,27 +781,23 @@ class HeadingFormatter: return def incChapter(self): - """Increment the chapter counter. - """ + """Increment the chapter counter.""" self._chCount += 1 return def incScene(self): - """Increment the scene counters. - """ + """Increment the scene counters.""" self._scChCount += 1 self._scAbsCount += 1 return def resetScene(self): - """Reset the chapter scene counter. - """ + """Reset the chapter scene counter.""" self._scChCount = 0 return def apply(self, hFormat: str, text: str): - """Apply formatting to a specific heading. - """ + """Apply formatting to a specific heading.""" hFormat = hFormat.replace(nwHeadFmt.TITLE, text) hFormat = hFormat.replace(nwHeadFmt.CH_NUM, str(self._chCount)) hFormat = hFormat.replace(nwHeadFmt.SC_NUM, str(self._scChCount)) diff --git a/novelwriter/core/tomd.py b/novelwriter/core/tomd.py index 8e05c40b..d6572ccd 100644 --- a/novelwriter/core/tomd.py +++ b/novelwriter/core/tomd.py @@ -179,6 +179,7 @@ class ToMarkdown(Tokenizer): """Save the data to a plain text file.""" with open(path, mode="w", encoding="utf-8") as outFile: outFile.write("".join(self._fullMD)) + logger.info("Wrote file: %s", path) return def replaceTabs(self, nSpaces: int = 8, spaceChar: str = " "): diff --git a/novelwriter/core/toodt.py b/novelwriter/core/toodt.py index 888e4e42..3bdb66b5 100644 --- a/novelwriter/core/toodt.py +++ b/novelwriter/core/toodt.py @@ -501,6 +501,7 @@ class ToOdt(Tokenizer): xml = ET.ElementTree(self._dFlat) xmlIndent(xml) xml.write(fObj, encoding="utf-8", xml_declaration=True) + logger.info("Wrote file: %s", path) return def saveOpenDocText(self, path: str | Path): @@ -535,6 +536,8 @@ class ToOdt(Tokenizer): putInZip("meta.xml", self._dMeta, outZip) putInZip("styles.xml", self._dStyl, outZip) + logger.info("Wrote file: %s", path) + return ## diff --git a/novelwriter/extensions/circularprogress.py b/novelwriter/extensions/circularprogress.py index dfaf586b..79dacfd1 100644 --- a/novelwriter/extensions/circularprogress.py +++ b/novelwriter/extensions/circularprogress.py @@ -21,6 +21,7 @@ General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . """ +from __future__ import annotations from math import ceil diff --git a/novelwriter/extensions/simpleprogress.py b/novelwriter/extensions/simpleprogress.py new file mode 100644 index 00000000..2dbf4e43 --- /dev/null +++ b/novelwriter/extensions/simpleprogress.py @@ -0,0 +1,54 @@ +""" +novelWriter – Custom Widget: Progress Simple +============================================ + +File History: +Created: 2023-06-09 [2.1b1] + +This file is a part of novelWriter +Copyright 2018–2023, 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 . +""" +from __future__ import annotations + +from math import ceil + +from PyQt5.QtGui import QPaintEvent, QPainter +from PyQt5.QtWidgets import QProgressBar, QWidget + + +class NProgressSimple(QProgressBar): + """Extension: Simple Progress Widget + + A custom widget that paints a plain bar with no other styling. + """ + + def __init__(self, parent: QWidget): + super().__init__(parent=parent) + return + + def paintEvent(self, event: QPaintEvent): + """Custom painter for the progress bar.""" + if self.value() == 0: + return + progress = ceil(self.width()*float(self.value())/self.maximum()) + qPaint = QPainter(self) + qPaint.setRenderHint(QPainter.Antialiasing, True) + qPaint.setPen(self.palette().highlight().color()) + qPaint.setBrush(self.palette().highlight()) + qPaint.drawRect(0, 0, progress, self.height()) + return + +# END Class NProgressSimple diff --git a/novelwriter/tools/manusbuild.py b/novelwriter/tools/manusbuild.py index f00f3bd0..3b5d6271 100644 --- a/novelwriter/tools/manusbuild.py +++ b/novelwriter/tools/manusbuild.py @@ -24,24 +24,25 @@ along with this program. If not, see . from __future__ import annotations import logging -from pathlib import Path from typing import TYPE_CHECKING +from pathlib import Path -from PyQt5.QtCore import QSize, Qt, pyqtSlot +from PyQt5.QtCore import QSize, QTimer, Qt, pyqtSlot from PyQt5.QtWidgets import ( QAbstractButton, QAbstractItemView, QDialog, QDialogButtonBox, QFileDialog, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, - QProgressBar, QPushButton, QSplitter, QVBoxLayout, QWidget + QPushButton, QSplitter, QVBoxLayout, QWidget ) from novelwriter import CONFIG -from novelwriter.enum import nwBuildFmt +from novelwriter.enum import nwAlert, nwBuildFmt from novelwriter.common import makeFileNameSafe from novelwriter.constants import nwLabels from novelwriter.core.item import NWItem from novelwriter.core.docbuild import NWBuildDocument from novelwriter.core.buildsettings import BuildSettings +from novelwriter.extensions.simpleprogress import NProgressSimple if TYPE_CHECKING: from novelwriter.guimain import GuiMain @@ -50,7 +51,7 @@ logger = logging.getLogger(__name__) class GuiManuscriptBuild(QDialog): - """GUI Tools: Manucript Builder Dialog + """GUI Tools: Manuscript Build Dialog This is the tool for running the build itself. It can be accessed independently of the Manuscript Build Tool. @@ -73,10 +74,13 @@ class GuiManuscriptBuild(QDialog): self.setWindowTitle(self.tr("Build Manuscript")) self.setMinimumWidth(CONFIG.pxInt(500)) - self.setMinimumHeight(CONFIG.pxInt(250)) + self.setMinimumHeight(CONFIG.pxInt(300)) iPx = self.mainTheme.baseIconSize - wWin = CONFIG.pxInt(660) + sp4 = CONFIG.pxInt(4) + sp8 = CONFIG.pxInt(8) + sp16 = CONFIG.pxInt(16) + wWin = CONFIG.pxInt(620) hWin = CONFIG.pxInt(360) pOptions = self.theProject.options @@ -141,35 +145,42 @@ class GuiManuscriptBuild(QDialog): self.lblMain.setWordWrap(True) self.lblMain.setFont(font) + # Build Path + self.lblPath = QLabel(self.tr("Path")) + self.buildPath = QLineEdit(self) + self.btnBrowse = QPushButton(self.mainTheme.getIcon("browse"), "") + + self.pathBox = QHBoxLayout() + self.pathBox.addWidget(self.buildPath) + self.pathBox.addWidget(self.btnBrowse) + self.pathBox.setSpacing(sp8) + # Build Name self.lblName = QLabel(self.tr("File Name")) - self.buildName = QLineEdit() + self.buildName = QLineEdit(self) self.btnReset = QPushButton(self.mainTheme.getIcon("revert"), "") self.btnReset.setToolTip(self.tr("Reset file name to default")) self.nameBox = QHBoxLayout() self.nameBox.addWidget(self.buildName) self.nameBox.addWidget(self.btnReset) + self.nameBox.setSpacing(sp8) # Build Progress - self.lblProgress = QLabel(self.tr("Progress")) - - self.buildProgress = QProgressBar() + self.buildProgress = NProgressSimple(self) self.buildProgress.setMinimum(0) self.buildProgress.setValue(0) - - self.progressBox = QVBoxLayout() - self.progressBox.addWidget(self.lblProgress) - self.progressBox.addWidget(self.buildProgress) - self.progressBox.setSpacing(CONFIG.pxInt(4)) + self.buildProgress.setTextVisible(False) + self.buildProgress.setFixedHeight(sp8) # Build Box self.buildBox = QGridLayout() - self.buildBox.addWidget(self.lblName, 1, 0) - self.buildBox.addLayout(self.nameBox, 1, 1) - self.buildBox.addWidget(self.lblProgress, 2, 0) - self.buildBox.addWidget(self.buildProgress, 2, 1) - self.buildBox.setVerticalSpacing(CONFIG.pxInt(4)) + self.buildBox.addWidget(self.lblPath, 0, 0) + self.buildBox.addLayout(self.pathBox, 0, 1) + self.buildBox.addWidget(self.lblName, 1, 0) + self.buildBox.addLayout(self.nameBox, 1, 1) + self.buildBox.setHorizontalSpacing(sp8) + self.buildBox.setVerticalSpacing(sp4) # Dialog Buttons self.btnBuild = QPushButton(self.mainTheme.getIcon("export"), self.tr("&Build")) @@ -182,7 +193,7 @@ class GuiManuscriptBuild(QDialog): self.mainSplit = QSplitter() self.mainSplit.addWidget(self.formatWidget) self.mainSplit.addWidget(self.contentWidget) - self.mainSplit.setHandleWidth(CONFIG.pxInt(16)) + self.mainSplit.setHandleWidth(sp16) self.mainSplit.setCollapsible(0, False) self.mainSplit.setCollapsible(1, False) self.mainSplit.setSizes([ @@ -192,15 +203,21 @@ class GuiManuscriptBuild(QDialog): self.outerBox = QVBoxLayout() self.outerBox.addWidget(self.lblMain, 0, Qt.AlignCenter) + self.outerBox.addSpacing(sp16) self.outerBox.addWidget(self.mainSplit, 1) + self.outerBox.addSpacing(sp4) + self.outerBox.addWidget(self.buildProgress, 0) + self.outerBox.addSpacing(sp4) self.outerBox.addLayout(self.buildBox, 0) + self.outerBox.addSpacing(sp16) self.outerBox.addWidget(self.dlgButtons, 0) - self.outerBox.setSpacing(CONFIG.pxInt(12)) + self.outerBox.setSpacing(0) self.setLayout(self.outerBox) self.btnBuild.setFocus() self._populateContentList() + self.buildPath.setText(str(self._build.lastPath)) if self._build.lastBuildName: self.buildName.setText(self._build.lastBuildName) else: @@ -208,6 +225,7 @@ class GuiManuscriptBuild(QDialog): # Signals self.btnReset.clicked.connect(self._doResetBuildName) + self.btnBrowse.clicked.connect(self._doSelectPath) self.dlgButtons.clicked.connect(self._dialogButtonClicked) self.listFormats.itemSelectionChanged.connect(self._formatSelectionChanged) @@ -253,6 +271,18 @@ class GuiManuscriptBuild(QDialog): self.close() return + @pyqtSlot() + def _doSelectPath(self): + """Select a folder for output.""" + bPath = Path(self.buildPath.text()) + bPath = bPath if bPath.is_dir() else self._build.lastPath + savePath = QFileDialog.getExistingDirectory( + self, self.tr("Select Folder"), str(bPath) + ) + if savePath: + self.buildPath.setText(savePath) + return + @pyqtSlot() def _doResetBuildName(self): """Generate a default build name.""" @@ -269,43 +299,55 @@ class GuiManuscriptBuild(QDialog): self.buildProgress.setValue(0) return + @pyqtSlot() + def _resetProgress(self): + """Set the progress bar back to 0.""" + self.buildProgress.setValue(0) + return + ## # Internal Functions ## def _runBuild(self) -> bool: """Run the currently selected build.""" - selFormat = self._getSelectedFormat() - if not isinstance(selFormat, nwBuildFmt): + bFormat = self._getSelectedFormat() + if not isinstance(bFormat, nwBuildFmt): return False - lastName = self.buildName.text().strip() - if not lastName: + bName = self.buildName.text().strip() + if not bName: self._doResetBuildName() - - lastPath = self._build.lastPath - selExt = nwLabels.BUILD_EXT[selFormat] - selName = Path(makeFileNameSafe(lastName)).with_suffix(selExt) + bName = self.buildName.text().strip() self.buildProgress.setValue(0) - savePath, _ = QFileDialog.getSaveFileName( - self, self.tr("Save Manuscript As"), str(lastPath / selName) - ) - if not savePath: + bPath = Path(self.buildPath.text()) + if not bPath.is_dir(): + self.mainGui.makeAlert(self.tr("Output folder does not exist."), nwAlert.ERROR) return False - buildPath = Path(savePath) + bExt = nwLabels.BUILD_EXT[bFormat] + buildPath = (bPath / makeFileNameSafe(bName)).with_suffix(bExt) + + if buildPath.exists(): + if not self.mainGui.askQuestion( + self.tr("File Exists"), + self.tr("The file already exists. Do you want to overwrite it?") + ): + return False docBuild = NWBuildDocument(self.theProject, self._build) docBuild.queueAll() self.buildProgress.setMaximum(len(docBuild)) - for i, _ in docBuild.iterBuild(buildPath, selFormat): + for i, _ in docBuild.iterBuild(buildPath, bFormat): self.buildProgress.setValue(i+1) - self._build.setLastFormat(selFormat) - self._build.setLastPath(buildPath.parent) - self._build.setLastBuildName(lastName) + self._build.setLastPath(bPath) + self._build.setLastBuildName(bName) + self._build.setLastFormat(bFormat) + + QTimer.singleShot(1000, self._resetProgress) return True diff --git a/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.htm b/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.htm index dcea6271..3d505118 100644 --- a/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.htm +++ b/tests/reference/mBuildDocBuild_HTML5_Lorem_Ipsum.htm @@ -18,7 +18,6 @@ a {color: rgb(66, 113, 174);} .break {text-align: left;} .synopsis {font-style: italic;} .comment {font-style: italic; color: rgb(100, 100, 100);} -article {width: 800px; margin: 40px auto;}
diff --git a/tests/test_core/test_core_tohtml.py b/tests/test_core/test_core_tohtml.py index e0a0d1b3..ebaf1860 100644 --- a/tests/test_core/test_core_tohtml.py +++ b/tests/test_core/test_core_tohtml.py @@ -517,7 +517,6 @@ def testCoreToHtml_Complex(mockGUI, fncPath): # ========== theStyle = theHtml.getStyleSheet() - theStyle.append("article {width: 800px; margin: 40px auto;}") htmlDoc = ( "\n" "\n" @@ -540,7 +539,7 @@ def testCoreToHtml_Complex(mockGUI, fncPath): ) saveFile = fncPath / "outFile.htm" - theHtml.saveHTML5(saveFile) + theHtml.saveHtml5(saveFile) assert readFile(saveFile) == htmlDoc # END Test testCoreToHtml_Complex From c344cf441eb4e0ca74eb746e62ec64f4f1790229 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Fri, 9 Jun 2023 21:31:02 +0200 Subject: [PATCH 72/74] Add back print functionality to manuscript tool --- novelwriter/tools/manusbuild.py | 2 +- novelwriter/tools/manuscript.py | 59 +++++++++++++++++++-------------- 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/novelwriter/tools/manusbuild.py b/novelwriter/tools/manusbuild.py index 3b5d6271..4f24ec1b 100644 --- a/novelwriter/tools/manusbuild.py +++ b/novelwriter/tools/manusbuild.py @@ -44,7 +44,7 @@ from novelwriter.core.docbuild import NWBuildDocument from novelwriter.core.buildsettings import BuildSettings from novelwriter.extensions.simpleprogress import NProgressSimple -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from novelwriter.guimain import GuiMain logger = logging.getLogger(__name__) diff --git a/novelwriter/tools/manuscript.py b/novelwriter/tools/manuscript.py index 2f5632db..be29542b 100644 --- a/novelwriter/tools/manuscript.py +++ b/novelwriter/tools/manuscript.py @@ -32,10 +32,10 @@ from datetime import datetime from PyQt5.QtGui import QColor, QCursor, QFont, QPalette, QResizeEvent from PyQt5.QtCore import QSize, QTimer, Qt, pyqtSlot +from PyQt5.QtPrintSupport import QPrintPreviewDialog, QPrinter from PyQt5.QtWidgets import ( - QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QMenu, - QPushButton, QSplitter, QTextBrowser, QToolButton, QVBoxLayout, QWidget, - qApp + QDialog, QGridLayout, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton, + QSplitter, QTextBrowser, QToolButton, QVBoxLayout, QWidget, qApp ) from novelwriter import CONFIG @@ -75,7 +75,7 @@ class GuiManuscript(QDialog): self.theProject = mainGui.theProject self._builds = BuildCollection(self.theProject) - self._buildMap = {} + self._buildMap: dict[str, QListWidgetItem] = {} self.setWindowTitle(self.tr("Build Manuscript")) self.setMinimumWidth(CONFIG.pxInt(600)) @@ -144,30 +144,23 @@ class GuiManuscript(QDialog): # Process Controls # ================ - self.btnPreview = QPushButton(self.tr("Build Preview")) + self.btnPreview = QPushButton(self.tr("Preview")) self.btnPreview.clicked.connect(self._generatePreview) + self.btnPrint = QPushButton(self.tr("Print")) + self.btnPrint.clicked.connect(self._printDocument) + self.btnBuild = QPushButton(self.tr("Build")) self.btnBuild.clicked.connect(self._buildManuscript) - self.menuPrint = QMenu(self) - self.aPrintSend = self.menuPrint.addAction(self.tr("Print Preview")) - self.aPrintFile = self.menuPrint.addAction(self.tr("Print to PDF")) - - self.btnPrint = QPushButton(self.tr("Print")) - self.btnPrint.setMenu(self.menuPrint) - self.btnClose = QPushButton(self.tr("Close")) self.btnClose.clicked.connect(self._doClose) - self.buildBox = QHBoxLayout() - self.buildBox.addWidget(self.btnBuild, 0) - self.buildBox.addWidget(self.btnPrint, 0) - self.buildBox.addWidget(self.btnClose, 0) - - self.processBox = QVBoxLayout() - self.processBox.addWidget(self.btnPreview) - self.processBox.addLayout(self.buildBox) + self.processBox = QGridLayout() + self.processBox.addWidget(self.btnPreview, 0, 0) + self.processBox.addWidget(self.btnPrint, 0, 1) + self.processBox.addWidget(self.btnBuild, 1, 0) + self.processBox.addWidget(self.btnClose, 1, 1) # Assemble GUI # ============ @@ -333,6 +326,14 @@ class GuiManuscript(QDialog): return + @pyqtSlot() + def _printDocument(self): + """Open the print preview dialog.""" + thePreview = QPrintPreviewDialog(self) + thePreview.paintRequested.connect(self.docPreview.printPreview) + thePreview.exec_() + return + @pyqtSlot() def _doClose(self): """Forward the close button to the default close method.""" @@ -354,6 +355,8 @@ class GuiManuscript(QDialog): self.docPreview.setJustify( build.getBool("format.justifyText") ) + if build.buildID and build.buildID in self._buildMap: + self._buildMap[build.buildID].setSelected(True) return def _getSelectedBuild(self) -> BuildSettings | None: @@ -365,10 +368,6 @@ class GuiManuscript(QDialog): return build return None - def _saveManuscript(self, outFormat: int): - """Save the manuscript file or files.""" - return - def _saveSettings(self): """Save the user GUI settings.""" logger.debug("Saving GuiManuscript settings") @@ -591,6 +590,18 @@ class _PreviewWidget(QTextBrowser): return ## + # Public Slots + ## + + @pyqtSlot("QPrinter*") + def printPreview(self, printer: QPrinter): + """Connect the print preview painter to the document viewer.""" + qApp.setOverrideCursor(QCursor(Qt.WaitCursor)) + printer.setOrientation(QPrinter.Portrait) + self.document().print(printer) + qApp.restoreOverrideCursor() + return + ## # Private Slots ## From 112aa8468cc0c3edef8d1de108a45b6e170314d8 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Fri, 9 Jun 2023 22:06:05 +0200 Subject: [PATCH 73/74] Finish up controls on manuscript build tool --- novelwriter/core/buildsettings.py | 29 +++++++++++++++++++---- novelwriter/tools/manuscript.py | 39 +++++++++++++++++++++---------- 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index 6f12472b..7e4ca251 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -34,6 +34,7 @@ from pathlib import Path from PyQt5.QtCore import QT_TRANSLATE_NOOP +from novelwriter import CONFIG from novelwriter.enum import nwBuildFmt from novelwriter.error import logException from novelwriter.common import checkUuid, isHandle, jsonEncode @@ -65,14 +66,14 @@ SETTINGS_TEMPLATE = { "text.includeBodyText": (bool, True), "text.addNoteHeadings": (bool, True), "format.buildLang": (str, "en_GB"), - "format.textFont": (str, ""), + "format.textFont": (str, CONFIG.textFont), "format.textSize": (int, 12), "format.lineHeight": (float, 1.15, 0.75, 3.0), "format.justifyText": (bool, False), "format.stripUnicode": (bool, False), "format.replaceTabs": (bool, False), "odt.addColours": (bool, True), - "html.addStyles": (bool, False), + "html.addStyles": (bool, True), } SETTINGS_LABELS = { @@ -434,8 +435,17 @@ class BuildCollection: def __init__(self, project: NWProject): self._project = project self._builds = {} + self._loadCollection() return + def __len__(self): + """Return the number of builds.""" + return len(self._builds) + + ## + # Methods + ## + def getBuild(self, buildID: str) -> BuildSettings | None: """Get a specific build settings object.""" if buildID not in self._builds: @@ -450,15 +460,26 @@ class BuildCollection: return False buildID = build.buildID self._builds[buildID] = build.pack() + self._saveCollection() return True + def removeBuild(self, buildID: str): + """Remove the a build from the collection.""" + self._builds.pop(buildID, None) + self._saveCollection() + return + def builds(self) -> Iterable[tuple[str, str]]: """Iterate over all avaiable builds.""" for buildID in self._builds: yield buildID, self._builds[buildID].get("name", "") return - def loadCollection(self) -> bool: + ## + # Internal Functions + ## + + def _loadCollection(self) -> bool: """Load build collections file.""" buildsFile = self._project.storage.getMetaFile(nwFiles.BUILDS_FILE) if not isinstance(buildsFile, Path): @@ -490,7 +511,7 @@ class BuildCollection: return True - def saveCollection(self) -> bool: + def _saveCollection(self) -> bool: """Save build collections file.""" buildsFile = self._project.storage.getMetaFile(nwFiles.BUILDS_FILE) if not isinstance(buildsFile, Path): diff --git a/novelwriter/tools/manuscript.py b/novelwriter/tools/manuscript.py index be29542b..5308dc63 100644 --- a/novelwriter/tools/manuscript.py +++ b/novelwriter/tools/manuscript.py @@ -114,8 +114,9 @@ class GuiManuscript(QDialog): self.tbDel = QToolButton(self) self.tbDel.setIcon(self.mainTheme.getIcon("remove")) self.tbDel.setIconSize(QSize(iPx, iPx)) - self.tbDel.setToolTip(self.tr("Remove Selected Build")) + self.tbDel.setToolTip(self.tr("Delete Selected Build")) self.tbDel.setStyleSheet(buttonStyle) + self.tbDel.clicked.connect(self._deleteSelectedBuild) self.tbEdit = QToolButton(self) self.tbEdit.setIcon(self.mainTheme.getIcon("edit")) @@ -125,12 +126,10 @@ class GuiManuscript(QDialog): self.tbEdit.clicked.connect(self._editSelectedBuild) self.lblBuilds = QLabel("{0}".format(self.tr("Builds"))) - self.buildList = QListWidget() - self.tbMore = QToolButton(self) - self.tbMore.setIcon(self.mainTheme.getIcon("menu")) - self.tbMore.setIconSize(QSize(iPx, iPx)) - self.tbMore.setStyleSheet(buttonStyle) + self.buildList = QListWidget() + self.buildList.setIconSize(QSize(iPx, iPx)) + self.buildList.doubleClicked.connect(self._editSelectedBuild) self.listToolBox = QHBoxLayout() self.listToolBox.addWidget(self.lblBuilds) @@ -138,7 +137,6 @@ class GuiManuscript(QDialog): self.listToolBox.addWidget(self.tbAdd) self.listToolBox.addWidget(self.tbDel) self.listToolBox.addWidget(self.tbEdit) - self.listToolBox.addWidget(self.tbMore) self.listToolBox.setSpacing(0) # Process Controls @@ -182,8 +180,8 @@ class GuiManuscript(QDialog): self.mainSplit.setStretchFactor(0, 0) self.mainSplit.setStretchFactor(1, 1) self.mainSplit.setSizes([ - CONFIG.pxInt(pOptions.getInt("GuiManuscript", "optsWidth", wWin//3)), - CONFIG.pxInt(pOptions.getInt("GuiManuscript", "viewWidth", 2*wWin//3)), + CONFIG.pxInt(pOptions.getInt("GuiManuscript", "optsWidth", wWin//4)), + CONFIG.pxInt(pOptions.getInt("GuiManuscript", "viewWidth", 3*wWin//4)), ]) self.outerBox = QVBoxLayout() @@ -203,7 +201,11 @@ class GuiManuscript(QDialog): def loadContent(self): """Load dialog content from project data.""" - self._builds.loadCollection() + if len(self._builds) == 0: + build = BuildSettings() + build.setName(self.tr("My Manuscript")) + self._builds.setBuild(build) + self._updateBuildsList() logger.debug("Loading build cache") @@ -260,11 +262,24 @@ class GuiManuscript(QDialog): self._openSettingsDialog(build) return + @pyqtSlot() + def _deleteSelectedBuild(self): + """Delete the currently selected build settings entry.""" + build = self._getSelectedBuild() + if build is not None: + proceed = self.mainGui.askQuestion( + self.tr("Delete Build"), + self.tr("Delete build '{0}'?".format(build.name)) + ) + if proceed: + self._builds.removeBuild(build.buildID) + self._updateBuildsList() + return + @pyqtSlot(BuildSettings) def _processNewSettings(self, build: BuildSettings): """Process new build settings from the settings dialog.""" self._builds.setBuild(build) - self._builds.saveCollection() self._updateBuildItem(build) return @@ -322,7 +337,6 @@ class GuiManuscript(QDialog): # After the build is done, save build settings changes if build.changed: self._builds.setBuild(build) - self._builds.saveCollection() return @@ -405,6 +419,7 @@ class GuiManuscript(QDialog): for key, name in self._builds.builds(): bItem = QListWidgetItem() bItem.setText(name) + bItem.setIcon(self.mainTheme.getIcon("export")) bItem.setData(self.D_KEY, key) self.buildList.addItem(bItem) self._buildMap[key] = bItem From 0eb39d2e0a92911f8711c585a6e9ddcb166bcb8f Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Fri, 9 Jun 2023 22:06:38 +0200 Subject: [PATCH 74/74] Clean up a few remnants of old test code --- .gitignore | 4 ---- tests/conftest.py | 21 --------------------- tests/test_dialogs/test_dlg_projdetails.py | 6 +++--- tests/test_gui/test_gui_doceditor.py | 4 ++-- tests/test_gui/test_gui_docviewer.py | 4 ++-- tests/test_gui/test_gui_mainmenu.py | 8 ++++---- tests/test_gui/test_gui_outline.py | 7 +++---- 7 files changed, 14 insertions(+), 40 deletions(-) diff --git a/.gitignore b/.gitignore index 20079fdc..72af1994 100644 --- a/.gitignore +++ b/.gitignore @@ -33,7 +33,6 @@ __pycache__ /novelwriter/assets/sample.zip /sample/cache /sample/meta -/sample/versions *.bak *.lock ToC.txt @@ -44,9 +43,6 @@ ToC.txt /tests/temp /tests/lipsum/cache /tests/lipsum/meta -/tests/minimal/cache -/tests/minimal/meta -/tests/oldproj/cache /.pytest_cache /pytestdebug.log diff --git a/tests/conftest.py b/tests/conftest.py index c8c84095..a12bdecc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -200,27 +200,6 @@ def mockRnd(monkeypatch): # Temp Project Folders ## -@pytest.fixture(scope="function") -def nwLipsum(): - """A medium sized novelWriter example project with a lot of Lorem - Ipsum text. - """ - srcDir = _TST_ROOT / "lipsum" - dstDir = _TMP_ROOT / "lipsum" - if dstDir.exists(): - shutil.rmtree(dstDir) - - shutil.copytree(srcDir, dstDir) - cleanProject(dstDir) - - yield str(dstDir) - - if dstDir.exists(): - shutil.rmtree(dstDir) - - return - - @pytest.fixture(scope="function") def prjLipsum(): """A medium sized novelWriter example project with a lot of Lorem diff --git a/tests/test_dialogs/test_dlg_projdetails.py b/tests/test_dialogs/test_dlg_projdetails.py index 50150257..8afd668e 100644 --- a/tests/test_dialogs/test_dlg_projdetails.py +++ b/tests/test_dialogs/test_dlg_projdetails.py @@ -29,11 +29,11 @@ from novelwriter.dialogs.projdetails import GuiProjectDetails @pytest.mark.gui -def testDlgProjDetails_Dialog(qtbot, nwGUI, nwLipsum): +def testDlgProjDetails_Dialog(qtbot, nwGUI, prjLipsum): """Test the project details dialog. """ # Create a project to work on - assert nwGUI.openProject(nwLipsum) + assert nwGUI.openProject(prjLipsum) assert nwGUI.rebuildIndex(beQuiet=True) qtbot.wait(100) @@ -56,7 +56,7 @@ def testDlgProjDetails_Dialog(qtbot, nwGUI, nwLipsum): assert projDet.tabMain.sceneCountVal.text() == f"{5:n}" assert projDet.tabMain.revCountVal.text() == f"{nwGUI.theProject.data.saveCount:n}" - assert projDet.tabMain.projPathVal.text() == nwLipsum + assert projDet.tabMain.projPathVal.text() == str(prjLipsum) # Contents Page # ============= diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index 1dc3610b..90032a57 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -1203,12 +1203,12 @@ def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, projPath, ipsumText, m @pytest.mark.gui -def testGuiEditor_Search(qtbot, monkeypatch, nwGUI, nwLipsum): +def testGuiEditor_Search(qtbot, monkeypatch, nwGUI, prjLipsum): """Test the document editor search functionality. """ monkeypatch.setattr(GuiDocEditor, "hasFocus", lambda *a: True) - assert nwGUI.openProject(nwLipsum) is True + assert nwGUI.openProject(prjLipsum) is True assert nwGUI.openDocument("4c4f28287af27") is True origText = nwGUI.docEditor.getText() diff --git a/tests/test_gui/test_gui_docviewer.py b/tests/test_gui/test_gui_docviewer.py index bb3437dd..9b524d31 100644 --- a/tests/test_gui/test_gui_docviewer.py +++ b/tests/test_gui/test_gui_docviewer.py @@ -33,11 +33,11 @@ from novelwriter.core.tohtml import ToHtml @pytest.mark.gui -def testGuiViewer_Main(qtbot, monkeypatch, nwGUI, nwLipsum): +def testGuiViewer_Main(qtbot, monkeypatch, nwGUI, prjLipsum): """Test the document viewer. """ # Open project - assert nwGUI.openProject(nwLipsum) + assert nwGUI.openProject(prjLipsum) # Rebuild the index nwGUI.mainMenu.aRebuildIndex.activate(QAction.Trigger) diff --git a/tests/test_gui/test_gui_mainmenu.py b/tests/test_gui/test_gui_mainmenu.py index 3c7cc565..2aaa851b 100644 --- a/tests/test_gui/test_gui_mainmenu.py +++ b/tests/test_gui/test_gui_mainmenu.py @@ -34,7 +34,7 @@ from novelwriter.gui.doceditor import GuiDocEditor @pytest.mark.gui -def testGuiMenu_EditFormat(qtbot, monkeypatch, nwGUI, nwLipsum): +def testGuiMenu_EditFormat(qtbot, monkeypatch, nwGUI, prjLipsum): """Test the main menu Edit and Format entries. """ monkeypatch.setattr(GuiDocEditor, "hasFocus", lambda *a: True) @@ -42,7 +42,7 @@ def testGuiMenu_EditFormat(qtbot, monkeypatch, nwGUI, nwLipsum): # Test Document Action with No Project assert nwGUI.docEditor.docAction(nwDocAction.COPY) is False - assert nwGUI.openProject(nwLipsum) is True + assert nwGUI.openProject(prjLipsum) is True # Split By Chapter assert nwGUI.openDocument("4c4f28287af27") is True @@ -342,10 +342,10 @@ def testGuiMenu_EditFormat(qtbot, monkeypatch, nwGUI, nwLipsum): @pytest.mark.gui -def testGuiMenu_ContextMenus(qtbot, nwGUI, nwLipsum): +def testGuiMenu_ContextMenus(qtbot, nwGUI, prjLipsum): """Test the context menus. """ - assert nwGUI.openProject(nwLipsum) + assert nwGUI.openProject(prjLipsum) assert nwGUI.openDocument("4c4f28287af27") # Editor Context Menu diff --git a/tests/test_gui/test_gui_outline.py b/tests/test_gui/test_gui_outline.py index 533a5c52..f208f246 100644 --- a/tests/test_gui/test_gui_outline.py +++ b/tests/test_gui/test_gui_outline.py @@ -19,7 +19,6 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import time import pytest @@ -152,10 +151,10 @@ def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, projPath): @pytest.mark.gui -def testGuiOutline_Content(qtbot, nwGUI, nwLipsum): +def testGuiOutline_Content(qtbot, nwGUI, prjLipsum): """Test the outline view. """ - assert nwGUI.openProject(nwLipsum) + assert nwGUI.openProject(prjLipsum) nwGUI.rebuildIndex() nwGUI._changeView(nwView.OUTLINE) @@ -192,7 +191,7 @@ def testGuiOutline_Content(qtbot, nwGUI, nwLipsum): for dTitle, hLevel in docList: aHandle = nwGUI.theProject.newFile(dTitle, newHandle) hHash = "#"*hLevel - writeFile(os.path.join(nwLipsum, "content", f"{aHandle}.nwd"), f"{hHash} {dTitle}\n\n") + writeFile(prjLipsum / "content" / f"{aHandle}.nwd", f"{hHash} {dTitle}\n\n") nwGUI.projView.projTree.revealNewTreeItem(aHandle) nwGUI.rebuildIndex()