novelWriter 2.2rc1__py3-none-any.whl → 2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. {novelWriter-2.2rc1.dist-info → novelWriter-2.3.dist-info}/METADATA +1 -1
  2. {novelWriter-2.2rc1.dist-info → novelWriter-2.3.dist-info}/RECORD +149 -132
  3. {novelWriter-2.2rc1.dist-info → novelWriter-2.3.dist-info}/WHEEL +1 -1
  4. novelWriter-2.3.dist-info/entry_points.txt +2 -0
  5. novelwriter/__init__.py +11 -6
  6. novelwriter/assets/i18n/nw_de_DE.qm +0 -0
  7. novelwriter/assets/i18n/nw_en_US.qm +0 -0
  8. novelwriter/assets/i18n/nw_es_419.qm +0 -0
  9. novelwriter/assets/i18n/nw_fr_FR.qm +0 -0
  10. novelwriter/assets/i18n/nw_it_IT.qm +0 -0
  11. novelwriter/assets/i18n/nw_ja_JP.qm +0 -0
  12. novelwriter/assets/i18n/nw_nb_NO.qm +0 -0
  13. novelwriter/assets/i18n/nw_nl_NL.qm +0 -0
  14. novelwriter/assets/i18n/nw_zh_CN.qm +0 -0
  15. novelwriter/assets/i18n/project_de_DE.json +1 -0
  16. novelwriter/assets/i18n/project_en_US.json +1 -0
  17. novelwriter/assets/i18n/project_es_419.json +11 -0
  18. novelwriter/assets/i18n/project_fr_FR.json +11 -0
  19. novelwriter/assets/i18n/project_it_IT.json +11 -0
  20. novelwriter/assets/i18n/project_ja_JP.json +2 -1
  21. novelwriter/assets/i18n/project_nb_NO.json +1 -0
  22. novelwriter/assets/i18n/project_nl_NL.json +11 -0
  23. novelwriter/assets/i18n/project_pt_BR.json +11 -0
  24. novelwriter/assets/i18n/project_zh_CN.json +11 -0
  25. novelwriter/assets/icons/typicons_dark/icons.conf +11 -2
  26. novelwriter/assets/icons/typicons_dark/mixed_document-new.svg +6 -0
  27. novelwriter/assets/icons/typicons_dark/mixed_import.svg +5 -0
  28. novelwriter/assets/icons/typicons_dark/nw_tb-bold-md.svg +4 -0
  29. novelwriter/assets/icons/typicons_dark/nw_tb-bold.svg +3 -1
  30. novelwriter/assets/icons/typicons_dark/nw_tb-italic-md.svg +4 -0
  31. novelwriter/assets/icons/typicons_dark/nw_tb-italic.svg +3 -1
  32. novelwriter/assets/icons/typicons_dark/nw_tb-strike-md.svg +4 -0
  33. novelwriter/assets/icons/typicons_dark/nw_tb-strike.svg +3 -1
  34. novelwriter/assets/icons/typicons_dark/nw_tb-subscript.svg +4 -2
  35. novelwriter/assets/icons/typicons_dark/nw_tb-superscript.svg +4 -2
  36. novelwriter/assets/icons/typicons_dark/nw_tb-underline.svg +4 -2
  37. novelwriter/assets/icons/typicons_dark/typ_document-add.svg +4 -0
  38. novelwriter/assets/icons/typicons_dark/typ_document.svg +4 -0
  39. novelwriter/assets/icons/typicons_dark/typ_th-dot-more.svg +4 -0
  40. novelwriter/assets/icons/typicons_dark/typ_th-list.svg +9 -0
  41. novelwriter/assets/icons/typicons_light/icons.conf +11 -2
  42. novelwriter/assets/icons/typicons_light/mixed_document-new.svg +6 -0
  43. novelwriter/assets/icons/typicons_light/mixed_import.svg +5 -0
  44. novelwriter/assets/icons/typicons_light/nw_tb-bold-md.svg +4 -0
  45. novelwriter/assets/icons/typicons_light/nw_tb-bold.svg +3 -1
  46. novelwriter/assets/icons/typicons_light/nw_tb-italic-md.svg +4 -0
  47. novelwriter/assets/icons/typicons_light/nw_tb-italic.svg +3 -1
  48. novelwriter/assets/icons/typicons_light/nw_tb-strike-md.svg +4 -0
  49. novelwriter/assets/icons/typicons_light/nw_tb-strike.svg +3 -1
  50. novelwriter/assets/icons/typicons_light/nw_tb-subscript.svg +4 -2
  51. novelwriter/assets/icons/typicons_light/nw_tb-superscript.svg +4 -2
  52. novelwriter/assets/icons/typicons_light/nw_tb-underline.svg +4 -2
  53. novelwriter/assets/icons/typicons_light/typ_document-add.svg +4 -0
  54. novelwriter/assets/icons/typicons_light/typ_document.svg +4 -0
  55. novelwriter/assets/icons/typicons_light/typ_th-dot-more.svg +4 -0
  56. novelwriter/assets/icons/typicons_light/typ_th-list.svg +9 -0
  57. novelwriter/assets/images/novelwriter-text-dark.svg +4 -0
  58. novelwriter/assets/images/novelwriter-text-light.svg +4 -0
  59. novelwriter/assets/images/welcome-dark.jpg +0 -0
  60. novelwriter/assets/images/welcome-light.jpg +0 -0
  61. novelwriter/assets/manual.pdf +0 -0
  62. novelwriter/assets/sample.zip +0 -0
  63. novelwriter/assets/syntax/cyberpunk_night.conf +26 -0
  64. novelwriter/assets/syntax/default_dark.conf +1 -0
  65. novelwriter/assets/syntax/default_light.conf +1 -0
  66. novelwriter/assets/syntax/grey_dark.conf +1 -0
  67. novelwriter/assets/syntax/grey_light.conf +1 -0
  68. novelwriter/assets/syntax/light_owl.conf +1 -0
  69. novelwriter/assets/syntax/night_owl.conf +1 -0
  70. novelwriter/assets/syntax/solarized_dark.conf +1 -0
  71. novelwriter/assets/syntax/solarized_light.conf +1 -0
  72. novelwriter/assets/syntax/tango.conf +23 -0
  73. novelwriter/assets/syntax/tomorrow.conf +1 -0
  74. novelwriter/assets/syntax/tomorrow_night.conf +1 -0
  75. novelwriter/assets/syntax/tomorrow_night_blue.conf +1 -0
  76. novelwriter/assets/syntax/tomorrow_night_bright.conf +1 -0
  77. novelwriter/assets/syntax/tomorrow_night_eighties.conf +1 -0
  78. novelwriter/assets/text/credits_en.htm +4 -2
  79. novelwriter/assets/themes/cyberpunk_night.conf +29 -0
  80. novelwriter/assets/themes/default_dark.conf +2 -2
  81. novelwriter/assets/themes/default_light.conf +2 -2
  82. novelwriter/common.py +64 -66
  83. novelwriter/config.py +39 -44
  84. novelwriter/constants.py +39 -17
  85. novelwriter/core/buildsettings.py +8 -8
  86. novelwriter/core/coretools.py +198 -157
  87. novelwriter/core/docbuild.py +7 -4
  88. novelwriter/core/document.py +7 -7
  89. novelwriter/core/index.py +90 -57
  90. novelwriter/core/item.py +23 -5
  91. novelwriter/core/options.py +11 -10
  92. novelwriter/core/project.py +73 -47
  93. novelwriter/core/projectdata.py +3 -16
  94. novelwriter/core/projectxml.py +14 -42
  95. novelwriter/core/sessions.py +4 -3
  96. novelwriter/core/spellcheck.py +6 -4
  97. novelwriter/core/status.py +5 -4
  98. novelwriter/core/storage.py +183 -141
  99. novelwriter/core/tohtml.py +6 -4
  100. novelwriter/core/tokenizer.py +110 -83
  101. novelwriter/core/tomd.py +2 -2
  102. novelwriter/core/toodt.py +41 -31
  103. novelwriter/core/tree.py +5 -4
  104. novelwriter/dialogs/about.py +88 -179
  105. novelwriter/dialogs/docmerge.py +30 -20
  106. novelwriter/dialogs/docsplit.py +33 -22
  107. novelwriter/dialogs/editlabel.py +20 -8
  108. novelwriter/dialogs/preferences.py +562 -725
  109. novelwriter/dialogs/{projsettings.py → projectsettings.py} +301 -270
  110. novelwriter/dialogs/quotes.py +47 -36
  111. novelwriter/dialogs/wordlist.py +128 -59
  112. novelwriter/enum.py +25 -22
  113. novelwriter/error.py +2 -2
  114. novelwriter/extensions/circularprogress.py +12 -12
  115. novelwriter/extensions/configlayout.py +185 -146
  116. novelwriter/extensions/{wheeleventfilter.py → eventfilters.py} +15 -5
  117. novelwriter/extensions/modified.py +81 -0
  118. novelwriter/extensions/novelselector.py +27 -13
  119. novelwriter/extensions/pagedsidebar.py +15 -20
  120. novelwriter/extensions/simpleprogress.py +8 -9
  121. novelwriter/extensions/statusled.py +9 -9
  122. novelwriter/extensions/switch.py +32 -64
  123. novelwriter/extensions/switchbox.py +2 -7
  124. novelwriter/extensions/versioninfo.py +153 -0
  125. novelwriter/gui/doceditor.py +250 -214
  126. novelwriter/gui/dochighlight.py +66 -94
  127. novelwriter/gui/docviewer.py +71 -98
  128. novelwriter/gui/docviewerpanel.py +140 -47
  129. novelwriter/gui/editordocument.py +3 -3
  130. novelwriter/gui/itemdetails.py +9 -9
  131. novelwriter/gui/mainmenu.py +47 -47
  132. novelwriter/gui/noveltree.py +53 -61
  133. novelwriter/gui/outline.py +100 -76
  134. novelwriter/gui/projtree.py +246 -112
  135. novelwriter/gui/sidebar.py +9 -8
  136. novelwriter/gui/statusbar.py +49 -7
  137. novelwriter/gui/theme.py +74 -76
  138. novelwriter/guimain.py +175 -330
  139. novelwriter/shared.py +68 -30
  140. novelwriter/tools/dictionaries.py +7 -8
  141. novelwriter/tools/lipsum.py +34 -28
  142. novelwriter/tools/manusbuild.py +3 -4
  143. novelwriter/tools/manuscript.py +25 -32
  144. novelwriter/tools/manussettings.py +194 -225
  145. novelwriter/tools/noveldetails.py +525 -0
  146. novelwriter/tools/welcome.py +819 -0
  147. novelwriter/tools/writingstats.py +26 -13
  148. novelWriter-2.2rc1.dist-info/entry_points.txt +0 -5
  149. novelwriter/assets/icons/typicons_dark/nw_tb-markdown.svg +0 -8
  150. novelwriter/assets/icons/typicons_dark/nw_tb-shortcode.svg +0 -8
  151. novelwriter/assets/icons/typicons_light/nw_tb-markdown.svg +0 -8
  152. novelwriter/assets/icons/typicons_light/nw_tb-shortcode.svg +0 -8
  153. novelwriter/assets/images/wizard-back.jpg +0 -0
  154. novelwriter/assets/text/gplv3_en.htm +0 -641
  155. novelwriter/assets/text/release_notes.htm +0 -17
  156. novelwriter/dialogs/projdetails.py +0 -525
  157. novelwriter/dialogs/projload.py +0 -298
  158. novelwriter/dialogs/updates.py +0 -182
  159. novelwriter/extensions/pageddialog.py +0 -130
  160. novelwriter/tools/projwizard.py +0 -478
  161. {novelWriter-2.2rc1.dist-info → novelWriter-2.3.dist-info}/LICENSE.md +0 -0
  162. {novelWriter-2.2rc1.dist-info → novelWriter-2.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,819 @@
1
+ """
2
+ novelWriter – GUI Welcome Dialog
3
+ ================================
4
+
5
+ File History:
6
+ Created: 2023-12-14 [2.3b1] GuiWelcome
7
+
8
+ This file is a part of novelWriter
9
+ Copyright 2018–2024, Veronica Berglyd Olsen
10
+
11
+ This program is free software: you can redistribute it and/or modify
12
+ it under the terms of the GNU General Public License as published by
13
+ the Free Software Foundation, either version 3 of the License, or
14
+ (at your option) any later version.
15
+
16
+ This program is distributed in the hope that it will be useful, but
17
+ WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
19
+ General Public License for more details.
20
+
21
+ You should have received a copy of the GNU General Public License
22
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+
28
+ from pathlib import Path
29
+ from datetime import datetime
30
+
31
+ from PyQt5.QtGui import QCloseEvent, QColor, QFont, QPaintEvent, QPainter, QPen
32
+ from PyQt5.QtCore import (
33
+ QAbstractListModel, QEvent, QModelIndex, QObject, QPoint, QSize, Qt,
34
+ pyqtSignal, pyqtSlot
35
+ )
36
+ from PyQt5.QtWidgets import (
37
+ QAction, QDialog, QFileDialog, QFormLayout, QHBoxLayout, QLabel, QLineEdit,
38
+ QListView, QMenu, QPushButton, QScrollArea, QShortcut, QStackedWidget,
39
+ QStyle, QStyleOptionViewItem, QStyledItemDelegate, QToolButton,
40
+ QVBoxLayout, QWidget, qApp
41
+ )
42
+
43
+ from novelwriter import CONFIG, SHARED
44
+ from novelwriter.enum import nwItemClass
45
+ from novelwriter.common import formatInt, makeFileNameSafe
46
+ from novelwriter.constants import nwFiles
47
+ from novelwriter.core.coretools import ProjectBuilder
48
+ from novelwriter.extensions.switch import NSwitch
49
+ from novelwriter.extensions.modified import NSpinBox
50
+ from novelwriter.extensions.versioninfo import VersionInfoWidget
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+ PANEL_ALPHA = 0.7
55
+
56
+
57
+ class GuiWelcome(QDialog):
58
+
59
+ openProjectRequest = pyqtSignal(Path)
60
+
61
+ def __init__(self, parent: QWidget) -> None:
62
+ super().__init__(parent=parent)
63
+
64
+ logger.debug("Create: GuiWelcome")
65
+ self.setObjectName("GuiWelcome")
66
+
67
+ self.setWindowTitle(self.tr("Welcome"))
68
+ self.setMinimumWidth(CONFIG.pxInt(650))
69
+ self.setMinimumHeight(CONFIG.pxInt(450))
70
+
71
+ hA = CONFIG.pxInt(8)
72
+ hB = CONFIG.pxInt(16)
73
+ hC = CONFIG.pxInt(24)
74
+ hD = CONFIG.pxInt(36)
75
+ hE = CONFIG.pxInt(48)
76
+ hF = CONFIG.pxInt(128)
77
+ self._hPx = CONFIG.pxInt(600)
78
+
79
+ self.resize(*CONFIG.welcomeWinSize)
80
+
81
+ # Elements
82
+ # ========
83
+
84
+ self.bgImage = SHARED.theme.loadDecoration("welcome")
85
+ self.nwImage = SHARED.theme.loadDecoration("nw-text", h=hD)
86
+ self.bgColor = QColor(255, 255, 255) if SHARED.theme.isLightTheme else QColor(54, 54, 54)
87
+
88
+ self.nwLogo = QLabel(self)
89
+ self.nwLogo.setPixmap(SHARED.theme.getPixmap("novelwriter", (hF, hF)))
90
+
91
+ self.nwLabel = QLabel(self)
92
+ self.nwLabel.setPixmap(self.nwImage)
93
+
94
+ self.nwInfo = VersionInfoWidget(self)
95
+
96
+ self.tabOpen = _OpenProjectPage(self)
97
+ self.tabOpen.openProjectRequest.connect(self._openProjectPath)
98
+
99
+ self.tabNew = _NewProjectPage(self)
100
+ self.tabNew.openProjectRequest.connect(self._openProjectPath)
101
+
102
+ self.mainStack = QStackedWidget(self)
103
+ self.mainStack.addWidget(self.tabOpen)
104
+ self.mainStack.addWidget(self.tabNew)
105
+
106
+ # Buttons
107
+ # =======
108
+
109
+ self.btnList = QPushButton(self.tr("List"), self)
110
+ self.btnList.setIcon(SHARED.theme.getIcon("list"))
111
+ self.btnList.clicked.connect(self._showOpenProjectPage)
112
+
113
+ self.btnNew = QPushButton(self.tr("New"), self)
114
+ self.btnNew.setIcon(SHARED.theme.getIcon("add"))
115
+ self.btnNew.clicked.connect(self._showNewProjectPage)
116
+
117
+ self.btnBrowse = QPushButton(self.tr("Browse"), self)
118
+ self.btnBrowse.setIcon(SHARED.theme.getIcon("browse"))
119
+ self.btnBrowse.clicked.connect(self._browseForProject)
120
+
121
+ self.btnCancel = QPushButton(self.tr("Cancel"), self)
122
+ self.btnCancel.setIcon(SHARED.theme.getIcon("cross"))
123
+ self.btnCancel.clicked.connect(self.close)
124
+
125
+ self.btnCreate = QPushButton(self.tr("Create"), self)
126
+ self.btnCreate.setIcon(SHARED.theme.getIcon("star"))
127
+ self.btnCreate.clicked.connect(self.tabNew.createNewProject)
128
+
129
+ self.btnOpen = QPushButton(self.tr("Open"), self)
130
+ self.btnOpen.setIcon(SHARED.theme.getIcon("open"))
131
+ self.btnOpen.clicked.connect(self._openSelectedItem)
132
+
133
+ self.btnBox = QHBoxLayout()
134
+ self.btnBox.addStretch(1)
135
+ self.btnBox.addWidget(self.btnList)
136
+ self.btnBox.addWidget(self.btnNew)
137
+ self.btnBox.addWidget(self.btnBrowse)
138
+ self.btnBox.addWidget(self.btnCancel)
139
+ self.btnBox.addWidget(self.btnCreate)
140
+ self.btnBox.addWidget(self.btnOpen)
141
+ self._setButtonVisibility()
142
+
143
+ # Assemble
144
+ # ========
145
+
146
+ self.innerBox = QVBoxLayout()
147
+ self.innerBox.addSpacing(hB)
148
+ self.innerBox.addWidget(self.nwLabel)
149
+ self.innerBox.addWidget(self.nwInfo)
150
+ self.innerBox.addSpacing(hA)
151
+ self.innerBox.addWidget(self.mainStack)
152
+ self.innerBox.addSpacing(hB)
153
+ self.innerBox.addLayout(self.btnBox)
154
+
155
+ topRight = Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight
156
+
157
+ self.outerBox = QHBoxLayout()
158
+ self.outerBox.addWidget(self.nwLogo, 3, topRight)
159
+ self.outerBox.addLayout(self.innerBox, 9)
160
+ self.outerBox.setContentsMargins(hF, hE, hC, hE)
161
+
162
+ self.setLayout(self.outerBox)
163
+ self.setSizeGripEnabled(True)
164
+
165
+ logger.debug("Ready: GuiWelcome")
166
+
167
+ return
168
+
169
+ def __del__(self) -> None: # pragma: no cover
170
+ logger.debug("Delete: GuiWelcome")
171
+ return
172
+
173
+ ##
174
+ # Events
175
+ ##
176
+
177
+ def paintEvent(self, event: QPaintEvent) -> None:
178
+ """Overload the paint event to draw the background image."""
179
+ hWin = self.height()
180
+ hPix = min(hWin, self._hPx)
181
+ tMode = Qt.TransformationMode.SmoothTransformation
182
+ painter = QPainter(self)
183
+ painter.fillRect(self.rect(), self.bgColor)
184
+ painter.drawPixmap(0, hWin - hPix, self.bgImage.scaledToHeight(hPix, tMode))
185
+ super().paintEvent(event)
186
+ return
187
+
188
+ def closeEvent(self, event: QCloseEvent) -> None:
189
+ """Capture the user closing the window and save settings."""
190
+ self._saveSettings()
191
+ event.accept()
192
+ self.deleteLater()
193
+ return
194
+
195
+ ##
196
+ # Private Slots
197
+ ##
198
+
199
+ @pyqtSlot()
200
+ def _showNewProjectPage(self) -> None:
201
+ """Show the create new project page."""
202
+ self.mainStack.setCurrentWidget(self.tabNew)
203
+ self._setButtonVisibility()
204
+ return
205
+
206
+ @pyqtSlot()
207
+ def _showOpenProjectPage(self) -> None:
208
+ """Show the open exiting project page."""
209
+ self.mainStack.setCurrentWidget(self.tabOpen)
210
+ self._setButtonVisibility()
211
+ return
212
+
213
+ @pyqtSlot()
214
+ def _browseForProject(self) -> None:
215
+ """Browse for a project to open."""
216
+ if path := SHARED.getProjectPath(self, path=CONFIG.lastPath(), allowZip=False):
217
+ CONFIG.setLastPath(path)
218
+ self._openProjectPath(path)
219
+ return
220
+
221
+ @pyqtSlot()
222
+ def _openSelectedItem(self) -> None:
223
+ """Open the currently selected project item."""
224
+ if self.mainStack.currentWidget() == self.tabOpen:
225
+ self.tabOpen.openSelectedItem()
226
+ return
227
+
228
+ @pyqtSlot(Path)
229
+ def _openProjectPath(self, path: Path) -> None:
230
+ """Emit a project open signal."""
231
+ if isinstance(path, Path):
232
+ # Hide before emitting the open project signal so that any
233
+ # close/backup dialogs don't pop up over it.
234
+ self.hide()
235
+ self.openProjectRequest.emit(path)
236
+ self.close()
237
+ return
238
+
239
+ ##
240
+ # Internal Functions
241
+ ##
242
+
243
+ def _saveSettings(self) -> None:
244
+ """Save the user GUI settings."""
245
+ logger.debug("Saving State: GuiWelcome")
246
+ CONFIG.setWelcomeWinSize(self.width(), self.height())
247
+ return
248
+
249
+ def _setButtonVisibility(self) -> None:
250
+ """Change the visibility of the dialog buttons."""
251
+ listMode = self.mainStack.currentWidget() == self.tabOpen
252
+ self.btnList.setVisible(not listMode)
253
+ self.btnNew.setVisible(listMode)
254
+ self.btnBrowse.setVisible(listMode)
255
+ self.btnCreate.setVisible(not listMode)
256
+ self.btnOpen.setVisible(listMode)
257
+ if listMode:
258
+ self.btnOpen.setFocus()
259
+ else:
260
+ self.btnCreate.setFocus()
261
+ return
262
+
263
+ # END Class GuiWelcome
264
+
265
+
266
+ class _OpenProjectPage(QWidget):
267
+
268
+ openProjectRequest = pyqtSignal(Path)
269
+
270
+ def __init__(self, parent: QWidget) -> None:
271
+ super().__init__(parent=parent)
272
+
273
+ # List View
274
+ self.listModel = _ProjectListModel(self)
275
+ self.itemDelegate = _ProjectListItem(self)
276
+
277
+ self.listWidget = QListView(self)
278
+ self.listWidget.setItemDelegate(self.itemDelegate)
279
+ self.listWidget.setModel(self.listModel)
280
+ self.listWidget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
281
+ self.listWidget.clicked.connect(self._projectClicked)
282
+ self.listWidget.doubleClicked.connect(self._projectDoubleClicked)
283
+ self.listWidget.customContextMenuRequested.connect(self._openContextMenu)
284
+
285
+ # Info / Tool
286
+ self.aMissing = QAction(self)
287
+ self.aMissing.setIcon(SHARED.theme.getIcon("alert_warn"))
288
+ self.aMissing.setToolTip(self.tr("The project path is not reachable."))
289
+
290
+ self.selectedPath = QLineEdit(self)
291
+ self.selectedPath.setReadOnly(True)
292
+ self.selectedPath.addAction(self.aMissing, QLineEdit.ActionPosition.TrailingPosition)
293
+
294
+ self.keyDelete = QShortcut(self)
295
+ self.keyDelete.setKey(Qt.Key.Key_Delete)
296
+ self.keyDelete.activated.connect(self._deleteSelectedItem)
297
+
298
+ # Assemble
299
+ self.outerBox = QVBoxLayout()
300
+ self.outerBox.addWidget(self.listWidget)
301
+ self.outerBox.addWidget(self.selectedPath)
302
+ self.outerBox.setContentsMargins(0, 0, 0, 0)
303
+
304
+ self.setLayout(self.outerBox)
305
+
306
+ self._selectFirstItem()
307
+
308
+ baseCol = self.palette().base().color()
309
+ self.setStyleSheet((
310
+ "QListView {{border: none; background: rgba({r},{g},{b},{a});}} "
311
+ "QLineEdit {{border: none; background: rgba({r},{g},{b},{a}); padding: {m}px;}} "
312
+ ).format(r=baseCol.red(), g=baseCol.green(), b=baseCol.blue(),
313
+ a=PANEL_ALPHA, m=CONFIG.pxInt(4)))
314
+
315
+ return
316
+
317
+ ##
318
+ # Public Slots
319
+ ##
320
+
321
+ @pyqtSlot()
322
+ def openSelectedItem(self) -> None:
323
+ """Open the currently selected project item."""
324
+ if (selection := self.listWidget.selectedIndexes()) and (index := selection[0]).isValid():
325
+ self.openProjectRequest.emit(Path(str(index.data()[1])))
326
+ return
327
+
328
+ ##
329
+ # Private Slots
330
+ ##
331
+
332
+ @pyqtSlot(QModelIndex)
333
+ def _projectClicked(self, index: QModelIndex) -> None:
334
+ """Process single click on project item."""
335
+ path = self.tr("Path")
336
+ value = index.data()[1] if index.isValid() else ""
337
+ text = f"{path}: {value}"
338
+ self.selectedPath.setText(text)
339
+ self.selectedPath.setToolTip(text)
340
+ self.selectedPath.setCursorPosition(0)
341
+ self.aMissing.setVisible(not (Path(value) / nwFiles.PROJ_FILE).is_file())
342
+ return
343
+
344
+ @pyqtSlot(QModelIndex)
345
+ def _projectDoubleClicked(self, index: QModelIndex) -> None:
346
+ """Process double click on project item."""
347
+ if index.isValid():
348
+ self.openProjectRequest.emit(Path(str(index.data()[1])))
349
+ return
350
+
351
+ @pyqtSlot()
352
+ def _deleteSelectedItem(self) -> None:
353
+ """Delete the currently selected project item."""
354
+ if (selection := self.listWidget.selectedIndexes()) and (index := selection[0]).isValid():
355
+ text = self.tr(
356
+ "Remove '{0}' from the recent projects list? "
357
+ "The project files will not be deleted."
358
+ ).format(index.data()[0])
359
+ if SHARED.question(text):
360
+ self.listModel.removeEntry(index)
361
+ self._selectFirstItem()
362
+ return
363
+
364
+ @pyqtSlot("QPoint")
365
+ def _openContextMenu(self, pos: QPoint) -> None:
366
+ """Open the custom context menu."""
367
+ ctxMenu = QMenu(self)
368
+ ctxMenu.setObjectName("ContextMenu") # Used for testing
369
+ action = ctxMenu.addAction(self.tr("Open Project"))
370
+ action.triggered.connect(self.openSelectedItem)
371
+ action = ctxMenu.addAction(self.tr("Remove Project"))
372
+ action.triggered.connect(self._deleteSelectedItem)
373
+ ctxMenu.exec_(self.mapToGlobal(pos))
374
+ ctxMenu.deleteLater()
375
+ return
376
+
377
+ ##
378
+ # Internal Functions
379
+ ##
380
+
381
+ def _selectFirstItem(self) -> None:
382
+ """Select the first item, if any are available."""
383
+ index = self.listModel.index(0)
384
+ self.listWidget.setCurrentIndex(index)
385
+ self._projectClicked(index)
386
+ return
387
+
388
+ # END Class _OpenProjectPage
389
+
390
+
391
+ class _ProjectListItem(QStyledItemDelegate):
392
+
393
+ __slots__ = ("_pPx", "_hPx", "_tFont", "_dFont", "_dPen", "_icon")
394
+
395
+ def __init__(self, parent: QWidget) -> None:
396
+ super().__init__(parent=parent)
397
+
398
+ fPx = SHARED.theme.fontPixelSize
399
+ fPt = SHARED.theme.fontPointSize
400
+ tPx = round(1.2 * fPx)
401
+ mPx = CONFIG.pxInt(4)
402
+ iPx = tPx + fPx
403
+
404
+ self._pPx = (mPx//2, 3*mPx//2, iPx + mPx, mPx, mPx + tPx) # Painter coordinates
405
+ self._hPx = 2*mPx + tPx + fPx # Fixed height
406
+
407
+ self._tFont = qApp.font()
408
+ self._tFont.setPointSizeF(1.2*fPt)
409
+ self._tFont.setWeight(QFont.Weight.Bold)
410
+
411
+ self._dFont = qApp.font()
412
+ self._dFont.setPointSizeF(fPt)
413
+ self._dPen = QPen(SHARED.theme.helpText)
414
+
415
+ self._icon = SHARED.theme.getPixmap("proj_nwx", (iPx, iPx))
416
+
417
+ return
418
+
419
+ def paint(self, painter: QPainter, opt: QStyleOptionViewItem, index: QModelIndex) -> None:
420
+ """Paint a project entry on the canvas."""
421
+ rect = opt.rect
422
+ title, _, details = index.data()
423
+ tFlag = Qt.TextFlag.TextSingleLine
424
+ ix, iy, x, y1, y2 = self._pPx
425
+
426
+ painter.save()
427
+ if opt.state & QStyle.StateFlag.State_Selected == QStyle.StateFlag.State_Selected:
428
+ painter.setOpacity(0.25)
429
+ painter.fillRect(rect, qApp.palette().highlight())
430
+ painter.setOpacity(1.0)
431
+
432
+ painter.drawPixmap(ix, rect.top() + iy, self._icon)
433
+ painter.setFont(self._tFont)
434
+ painter.drawText(rect.adjusted(x, y1, 0, 0), tFlag, title)
435
+ painter.setFont(self._dFont)
436
+ painter.setPen(self._dPen)
437
+ painter.drawText(rect.adjusted(x, y2, 0, 0), tFlag, details)
438
+ painter.restore()
439
+
440
+ return
441
+
442
+ def sizeHint(self, opt: QStyleOptionViewItem, index: QModelIndex) -> QSize:
443
+ """Set the size hint to fixed height."""
444
+ return QSize(opt.rect.width(), self._hPx)
445
+
446
+ # END Class _ProjectListItem
447
+
448
+
449
+ class _ProjectListModel(QAbstractListModel):
450
+
451
+ def __init__(self, parent: QObject) -> None:
452
+ super().__init__(parent=parent)
453
+ data = []
454
+ words = self.tr("Word Count")
455
+ opened = self.tr("Last Opened")
456
+ records = sorted(CONFIG.recentProjects.listEntries(), key=lambda x: x[3], reverse=True)
457
+ for path, title, count, time in records:
458
+ when = datetime.fromtimestamp(time).strftime("%x")
459
+ data.append((title, path, f"{opened}: {when}, {words}: {formatInt(count)}"))
460
+ self._data = data
461
+ return
462
+
463
+ def rowCount(self, parent: QModelIndex | None = None) -> int:
464
+ """Return the size of the model."""
465
+ return len(self._data)
466
+
467
+ def data(self, index: QModelIndex, role: int = 0) -> tuple[str, str, str]:
468
+ """Return data for an individual item."""
469
+ try:
470
+ return self._data[index.row()] if index.isValid() else ("", "", "")
471
+ except IndexError:
472
+ return "", "", ""
473
+
474
+ def removeEntry(self, index: QModelIndex) -> bool:
475
+ """Remove an entry in the model."""
476
+ if index.isValid() and (path := index.data()[1]):
477
+ try:
478
+ self.beginRemoveRows(index.parent(), index.row(), index.row())
479
+ self._data.pop(index.row())
480
+ self.endRemoveRows()
481
+ except IndexError:
482
+ return False
483
+ CONFIG.recentProjects.remove(path)
484
+ return True
485
+ return False
486
+
487
+ # END Class _ProjectListModel
488
+
489
+
490
+ class _NewProjectPage(QWidget):
491
+
492
+ openProjectRequest = pyqtSignal(Path)
493
+
494
+ def __init__(self, parent: QWidget) -> None:
495
+ super().__init__(parent=parent)
496
+
497
+ # Main Form
498
+ # =========
499
+
500
+ self.projectForm = _NewProjectForm(self)
501
+
502
+ self.scrollArea = QScrollArea(self)
503
+ self.scrollArea.setWidget(self.projectForm)
504
+ self.scrollArea.setWidgetResizable(True)
505
+ self.scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
506
+ self.scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
507
+
508
+ # Assemble
509
+ # ========
510
+
511
+ self.outerBox = QVBoxLayout()
512
+ self.outerBox.addWidget(self.scrollArea)
513
+ self.outerBox.setContentsMargins(0, 0, 0, 0)
514
+
515
+ self.setLayout(self.outerBox)
516
+
517
+ # Styles
518
+ # ======
519
+
520
+ baseCol = self.palette().base().color()
521
+ self.setStyleSheet((
522
+ "QScrollArea {{border: none; background: rgba({r},{g},{b},{a});}} "
523
+ "_NewProjectForm {{border: none; background: rgba({r},{g},{b},{a});}} "
524
+ ).format(r=baseCol.red(), g=baseCol.green(), b=baseCol.blue(), a=PANEL_ALPHA))
525
+
526
+ return
527
+
528
+ ##
529
+ # Public Slots
530
+ ##
531
+
532
+ @pyqtSlot()
533
+ def createNewProject(self) -> None:
534
+ """Create a new project from the data in the form."""
535
+ data = self.projectForm.getProjectData()
536
+ if not data.get("name"):
537
+ SHARED.error(self.tr("A project name is required."))
538
+ return
539
+ builder = ProjectBuilder()
540
+ if builder.buildProject(data) and (path := builder.projPath):
541
+ self.openProjectRequest.emit(path)
542
+ return
543
+
544
+ # END Class _NewProjectPage
545
+
546
+
547
+ class _NewProjectForm(QWidget):
548
+
549
+ FILL_BLANK = 0
550
+ FILL_SAMPLE = 1
551
+ FILL_COPY = 2
552
+
553
+ def __init__(self, parent: QWidget) -> None:
554
+ super().__init__(parent=parent)
555
+
556
+ self._basePath = CONFIG.lastPath()
557
+ self._fillMode = self.FILL_BLANK
558
+ self._copyPath = None
559
+
560
+ iPx = SHARED.theme.baseIconSize
561
+ sPx = CONFIG.pxInt(16)
562
+
563
+ # Project Settings
564
+ # ================
565
+
566
+ # Project Name
567
+ self.projName = QLineEdit(self)
568
+ self.projName.setMaxLength(200)
569
+ self.projName.setPlaceholderText(self.tr("Required"))
570
+ self.projName.textChanged.connect(self._updateProjPath)
571
+
572
+ # Author(s)
573
+ self.projAuthor = QLineEdit(self)
574
+ self.projAuthor.setMaxLength(200)
575
+ self.projAuthor.setPlaceholderText(self.tr("Optional"))
576
+
577
+ # Project Path
578
+ self.projPath = QLineEdit(self)
579
+ self.projPath.setReadOnly(True)
580
+
581
+ self.browsePath = QToolButton(self)
582
+ self.browsePath.setIcon(SHARED.theme.getIcon("browse"))
583
+ self.browsePath.clicked.connect(self._doBrowse)
584
+
585
+ self.pathBox = QHBoxLayout()
586
+ self.pathBox.addWidget(self.projPath)
587
+ self.pathBox.addWidget(self.browsePath)
588
+
589
+ # Fill Project
590
+ self.projFill = QLineEdit(self)
591
+ self.projFill.setReadOnly(True)
592
+
593
+ self.browseFill = QToolButton(self)
594
+ self.browseFill.setIcon(SHARED.theme.getIcon("add_document"))
595
+
596
+ self.fillMenu = _PopLeftDirectionMenu(self.browseFill)
597
+
598
+ self.fillBlank = self.fillMenu.addAction(self.tr("Create a fresh project"))
599
+ self.fillBlank.setIcon(SHARED.theme.getIcon("document"))
600
+ self.fillBlank.triggered.connect(self._setFillBlank)
601
+
602
+ self.fillSample = self.fillMenu.addAction(self.tr("Create an example project"))
603
+ self.fillSample.setIcon(SHARED.theme.getIcon("add_document"))
604
+ self.fillSample.triggered.connect(self._setFillSample)
605
+
606
+ self.fillCopy = self.fillMenu.addAction(self.tr("Copy an existing project"))
607
+ self.fillCopy.setIcon(SHARED.theme.getIcon("browse"))
608
+ self.fillCopy.triggered.connect(self._setFillCopy)
609
+
610
+ self.browseFill.setMenu(self.fillMenu)
611
+ self.browseFill.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
612
+
613
+ self.fillBox = QHBoxLayout()
614
+ self.fillBox.addWidget(self.projFill)
615
+ self.fillBox.addWidget(self.browseFill)
616
+
617
+ # Project Form
618
+ self.projectForm = QFormLayout()
619
+ self.projectForm.setAlignment(Qt.AlignmentFlag.AlignLeft)
620
+ self.projectForm.addRow(self.tr("Project Name"), self.projName)
621
+ self.projectForm.addRow(self.tr("Author"), self.projAuthor)
622
+ self.projectForm.addRow(self.tr("Project Path"), self.pathBox)
623
+ self.projectForm.addRow(self.tr("Prefill Project"), self.fillBox)
624
+
625
+ # Chapters and Scenes
626
+ # ===================
627
+
628
+ self.numChapters = NSpinBox(self)
629
+ self.numChapters.setRange(0, 200)
630
+ self.numChapters.setValue(5)
631
+ self.numChapters.setToolTip(self.tr("Set to 0 to only add scenes"))
632
+
633
+ self.chapterBox = QHBoxLayout()
634
+ self.chapterBox.addWidget(QLabel(self.tr("Add")))
635
+ self.chapterBox.addWidget(self.numChapters)
636
+ self.chapterBox.addWidget(QLabel(self.tr("chapter documents")))
637
+ self.chapterBox.addStretch(1)
638
+
639
+ self.numScenes = NSpinBox(self)
640
+ self.numScenes.setRange(0, 200)
641
+ self.numScenes.setValue(5)
642
+
643
+ self.sceneBox = QHBoxLayout()
644
+ self.sceneBox.addWidget(QLabel(self.tr("Add")))
645
+ self.sceneBox.addWidget(self.numScenes)
646
+ self.sceneBox.addWidget(QLabel(self.tr("scene documents (to each chapter)")))
647
+ self.sceneBox.addStretch(1)
648
+
649
+ self.novelForm = QVBoxLayout()
650
+ self.novelForm.addLayout(self.chapterBox)
651
+ self.novelForm.addLayout(self.sceneBox)
652
+
653
+ # Project Notes
654
+ # =============
655
+
656
+ self.addPlot = NSwitch(self, height=iPx)
657
+ self.addPlot.setChecked(True)
658
+ self.addPlot.clicked.connect(self._syncSwitches)
659
+
660
+ self.addChar = NSwitch(self, height=iPx)
661
+ self.addChar.setChecked(True)
662
+ self.addChar.clicked.connect(self._syncSwitches)
663
+
664
+ self.addWorld = NSwitch(self, height=iPx)
665
+ self.addWorld.setChecked(False)
666
+ self.addWorld.clicked.connect(self._syncSwitches)
667
+
668
+ self.addNotes = NSwitch(self, height=iPx)
669
+ self.addNotes.setChecked(False)
670
+
671
+ self.notesForm = QFormLayout()
672
+ self.notesForm.setAlignment(Qt.AlignmentFlag.AlignLeft)
673
+ self.notesForm.addRow(self.tr("Add a folder for plot notes"), self.addPlot)
674
+ self.notesForm.addRow(self.tr("Add a folder for character notes"), self.addChar)
675
+ self.notesForm.addRow(self.tr("Add a folder for location notes"), self.addWorld)
676
+ self.notesForm.addRow(self.tr("Add example notes to the above"), self.addNotes)
677
+
678
+ # Assemble
679
+ # ========
680
+
681
+ self.extraBox = QVBoxLayout()
682
+ self.extraBox.addWidget(QLabel("<b>{0}</b>".format(self.tr("Chapters and Scenes"))))
683
+ self.extraBox.addLayout(self.novelForm)
684
+ self.extraBox.addSpacing(sPx)
685
+ self.extraBox.addWidget(QLabel("<b>{0}</b>".format(self.tr("Project Notes"))))
686
+ self.extraBox.addLayout(self.notesForm)
687
+ self.extraBox.setContentsMargins(0, 0, 0, 0)
688
+
689
+ self.extraWidget = QWidget(self)
690
+ self.extraWidget.setLayout(self.extraBox)
691
+ self.extraWidget.setContentsMargins(0, 0, 0, 0)
692
+
693
+ self.formBox = QVBoxLayout()
694
+ self.formBox.addWidget(QLabel("<b>{0}</b>".format(self.tr("Create New Project"))))
695
+ self.formBox.addLayout(self.projectForm)
696
+ self.formBox.addSpacing(sPx)
697
+ self.formBox.addWidget(self.extraWidget)
698
+ self.formBox.addStretch(1)
699
+
700
+ self.setLayout(self.formBox)
701
+
702
+ self._updateProjPath()
703
+ self._updateFillInfo()
704
+
705
+ return
706
+
707
+ def getProjectData(self) -> dict:
708
+ """Collect form data and return it as a dictionary."""
709
+ roots = []
710
+ if self.addPlot.isChecked():
711
+ roots.append(nwItemClass.PLOT)
712
+ if self.addChar.isChecked():
713
+ roots.append(nwItemClass.CHARACTER)
714
+ if self.addWorld.isChecked():
715
+ roots.append(nwItemClass.WORLD)
716
+ return {
717
+ "name": self.projName.text().strip(),
718
+ "author": self.projAuthor.text().strip(),
719
+ "path": self.projPath.text(),
720
+ "blank": self._fillMode == self.FILL_BLANK,
721
+ "sample": self._fillMode == self.FILL_SAMPLE,
722
+ "template": self._copyPath if self._fillMode == self.FILL_COPY else None,
723
+ "chapters": self.numChapters.value(),
724
+ "scenes": self.numScenes.value(),
725
+ "roots": roots,
726
+ "notes": self.addNotes.isChecked(),
727
+ }
728
+
729
+ ##
730
+ # Private Slots
731
+ ##
732
+
733
+ @pyqtSlot()
734
+ def _doBrowse(self) -> None:
735
+ """Select a project folder."""
736
+ if projDir := QFileDialog.getExistingDirectory(
737
+ self, self.tr("Select Project Folder"),
738
+ str(self._basePath), options=QFileDialog.ShowDirsOnly
739
+ ):
740
+ self._basePath = Path(projDir)
741
+ self._updateProjPath()
742
+ CONFIG.setLastPath(self._basePath)
743
+ return
744
+
745
+ @pyqtSlot()
746
+ def _updateProjPath(self) -> None:
747
+ """Update the path box to show the full project path."""
748
+ projName = makeFileNameSafe(self.projName.text().strip())
749
+ self.projPath.setText(str(self._basePath / projName))
750
+ return
751
+
752
+ @pyqtSlot()
753
+ def _syncSwitches(self):
754
+ """Check if the add notes option should also be switched off."""
755
+ addPlot = self.addPlot.isChecked()
756
+ addChar = self.addChar.isChecked()
757
+ addWorld = self.addWorld.isChecked()
758
+ if not (addPlot or addChar or addWorld):
759
+ self.addNotes.setChecked(False)
760
+ return
761
+
762
+ @pyqtSlot()
763
+ def _setFillBlank(self) -> None:
764
+ """Set fill mode to blank project."""
765
+ self._fillMode = self.FILL_BLANK
766
+ self._updateFillInfo()
767
+ return
768
+
769
+ @pyqtSlot()
770
+ def _setFillSample(self) -> None:
771
+ """Set fill mode to sample project."""
772
+ self._fillMode = self.FILL_SAMPLE
773
+ self._updateFillInfo()
774
+ return
775
+
776
+ @pyqtSlot()
777
+ def _setFillCopy(self) -> None:
778
+ """Set fill mode to copy project."""
779
+ if copyPath := SHARED.getProjectPath(self, allowZip=True):
780
+ self._fillMode = self.FILL_COPY
781
+ self._copyPath = copyPath
782
+ self._updateFillInfo()
783
+ return
784
+
785
+ ##
786
+ # Internal Functions
787
+ ##
788
+
789
+ def _updateFillInfo(self) -> None:
790
+ """Update the text of the project fill box."""
791
+ text = ""
792
+ if self._fillMode == self.FILL_BLANK:
793
+ text = self.tr("Fresh Project")
794
+ elif self._fillMode == self.FILL_SAMPLE:
795
+ text = self.tr("Example Project")
796
+ elif self._fillMode == self.FILL_COPY:
797
+ text = self.tr("Template: {0}").format(str(self._copyPath))
798
+
799
+ self.projFill.setText(text)
800
+ self.projFill.setToolTip(text)
801
+ self.projFill.setCursorPosition(0)
802
+ self.extraWidget.setVisible(self._fillMode == self.FILL_BLANK)
803
+
804
+ return
805
+
806
+ # END Class _NewProjectForm
807
+
808
+
809
+ class _PopLeftDirectionMenu(QMenu):
810
+
811
+ def event(self, event: QEvent) -> bool:
812
+ """Overload the show event and move the menu popup location."""
813
+ if event.type() == QEvent.Show:
814
+ if isinstance(parent := self.parent(), QWidget):
815
+ offset = QPoint(parent.width() - self.width(), parent.height())
816
+ self.move(parent.mapToGlobal(offset))
817
+ return super(_PopLeftDirectionMenu, self).event(event)
818
+
819
+ # END Class _PopLeftDirectionMenu