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