novelWriter 2.5.3__py3-none-any.whl → 2.6b2__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 (83) hide show
  1. {novelWriter-2.5.3.dist-info → novelWriter-2.6b2.dist-info}/METADATA +1 -1
  2. {novelWriter-2.5.3.dist-info → novelWriter-2.6b2.dist-info}/RECORD +80 -60
  3. novelwriter/__init__.py +49 -10
  4. novelwriter/assets/i18n/project_en_GB.json +1 -0
  5. novelwriter/assets/icons/typicons_dark/icons.conf +8 -0
  6. novelwriter/assets/icons/typicons_dark/mixed_copy.svg +4 -0
  7. novelwriter/assets/icons/typicons_dark/mixed_margin-bottom.svg +6 -0
  8. novelwriter/assets/icons/typicons_dark/mixed_margin-left.svg +6 -0
  9. novelwriter/assets/icons/typicons_dark/mixed_margin-right.svg +6 -0
  10. novelwriter/assets/icons/typicons_dark/mixed_margin-top.svg +6 -0
  11. novelwriter/assets/icons/typicons_dark/mixed_size-height.svg +6 -0
  12. novelwriter/assets/icons/typicons_dark/mixed_size-width.svg +6 -0
  13. novelwriter/assets/icons/typicons_dark/nw_toolbar.svg +5 -0
  14. novelwriter/assets/icons/typicons_light/icons.conf +8 -0
  15. novelwriter/assets/icons/typicons_light/mixed_copy.svg +4 -0
  16. novelwriter/assets/icons/typicons_light/mixed_margin-bottom.svg +6 -0
  17. novelwriter/assets/icons/typicons_light/mixed_margin-left.svg +6 -0
  18. novelwriter/assets/icons/typicons_light/mixed_margin-right.svg +6 -0
  19. novelwriter/assets/icons/typicons_light/mixed_margin-top.svg +6 -0
  20. novelwriter/assets/icons/typicons_light/mixed_size-height.svg +6 -0
  21. novelwriter/assets/icons/typicons_light/mixed_size-width.svg +6 -0
  22. novelwriter/assets/icons/typicons_light/nw_toolbar.svg +5 -0
  23. novelwriter/assets/manual.pdf +0 -0
  24. novelwriter/assets/sample.zip +0 -0
  25. novelwriter/common.py +100 -2
  26. novelwriter/config.py +25 -15
  27. novelwriter/constants.py +168 -60
  28. novelwriter/core/buildsettings.py +66 -39
  29. novelwriter/core/coretools.py +145 -147
  30. novelwriter/core/docbuild.py +132 -170
  31. novelwriter/core/index.py +38 -37
  32. novelwriter/core/item.py +41 -8
  33. novelwriter/core/itemmodel.py +518 -0
  34. novelwriter/core/options.py +4 -1
  35. novelwriter/core/project.py +67 -89
  36. novelwriter/core/spellcheck.py +9 -14
  37. novelwriter/core/status.py +7 -5
  38. novelwriter/core/tree.py +268 -287
  39. novelwriter/dialogs/docmerge.py +7 -17
  40. novelwriter/dialogs/preferences.py +46 -33
  41. novelwriter/dialogs/projectsettings.py +5 -5
  42. novelwriter/enum.py +36 -23
  43. novelwriter/extensions/configlayout.py +27 -12
  44. novelwriter/extensions/modified.py +13 -1
  45. novelwriter/extensions/pagedsidebar.py +5 -5
  46. novelwriter/formats/shared.py +155 -0
  47. novelwriter/formats/todocx.py +1191 -0
  48. novelwriter/formats/tohtml.py +451 -0
  49. novelwriter/{core → formats}/tokenizer.py +487 -491
  50. novelwriter/formats/tomarkdown.py +217 -0
  51. novelwriter/{core → formats}/toodt.py +311 -432
  52. novelwriter/formats/toqdoc.py +484 -0
  53. novelwriter/formats/toraw.py +91 -0
  54. novelwriter/gui/doceditor.py +342 -284
  55. novelwriter/gui/dochighlight.py +96 -84
  56. novelwriter/gui/docviewer.py +88 -31
  57. novelwriter/gui/docviewerpanel.py +17 -25
  58. novelwriter/gui/editordocument.py +17 -2
  59. novelwriter/gui/itemdetails.py +25 -28
  60. novelwriter/gui/mainmenu.py +129 -63
  61. novelwriter/gui/noveltree.py +45 -47
  62. novelwriter/gui/outline.py +196 -249
  63. novelwriter/gui/projtree.py +594 -1241
  64. novelwriter/gui/search.py +9 -10
  65. novelwriter/gui/sidebar.py +7 -6
  66. novelwriter/gui/theme.py +10 -5
  67. novelwriter/guimain.py +100 -196
  68. novelwriter/shared.py +66 -27
  69. novelwriter/text/counting.py +2 -0
  70. novelwriter/text/patterns.py +168 -60
  71. novelwriter/tools/manusbuild.py +14 -12
  72. novelwriter/tools/manuscript.py +120 -78
  73. novelwriter/tools/manussettings.py +424 -291
  74. novelwriter/tools/welcome.py +4 -4
  75. novelwriter/tools/writingstats.py +3 -3
  76. novelwriter/types.py +23 -7
  77. novelwriter/core/tohtml.py +0 -530
  78. novelwriter/core/tomarkdown.py +0 -252
  79. novelwriter/core/toqdoc.py +0 -419
  80. {novelWriter-2.5.3.dist-info → novelWriter-2.6b2.dist-info}/LICENSE.md +0 -0
  81. {novelWriter-2.5.3.dist-info → novelWriter-2.6b2.dist-info}/WHEEL +0 -0
  82. {novelWriter-2.5.3.dist-info → novelWriter-2.6b2.dist-info}/entry_points.txt +0 -0
  83. {novelWriter-2.5.3.dist-info → novelWriter-2.6b2.dist-info}/top_level.txt +0 -0
@@ -3,10 +3,12 @@ novelWriter – GUI Project Tree
3
3
  ==============================
4
4
 
5
5
  File History:
6
- Created: 2018-09-29 [0.0.1] GuiProjectTree
7
- Created: 2022-06-06 [2.0rc1] GuiProjectView
8
- Created: 2022-06-06 [2.0rc1] GuiProjectToolBar
9
- Created: 2023-11-22 [2.2rc1] _TreeContextMenu
6
+ Created: 2018-09-29 [0.0.1] GuiProjectTree
7
+ Created: 2022-06-06 [2.0rc1] GuiProjectView
8
+ Created: 2022-06-06 [2.0rc1] GuiProjectToolBar
9
+ Created: 2023-11-22 [2.2rc1] _TreeContextMenu
10
+ Rewritten: 2024-11-17 [2.6b2] GuiProjectTree
11
+ Rewritten: 2024-11-20 [2.6b2] _TreeContextMenu
10
12
 
11
13
  This file is a part of novelWriter
12
14
  Copyright 2018–2024, Veronica Berglyd Olsen
@@ -29,30 +31,30 @@ from __future__ import annotations
29
31
  import logging
30
32
 
31
33
  from enum import Enum
32
- from time import time
33
34
 
34
- from PyQt5.QtCore import QPoint, Qt, QTimer, pyqtSignal, pyqtSlot
35
- from PyQt5.QtGui import QDragEnterEvent, QDragMoveEvent, QDropEvent, QIcon, QMouseEvent, QPalette
35
+ from PyQt5.QtCore import QModelIndex, QPoint, Qt, pyqtSignal, pyqtSlot
36
+ from PyQt5.QtGui import QIcon, QMouseEvent, QPainter, QPalette
36
37
  from PyQt5.QtWidgets import (
37
- QAbstractItemView, QAction, QFrame, QHBoxLayout, QHeaderView, QLabel,
38
- QMenu, QShortcut, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
38
+ QAbstractItemView, QAction, QFrame, QHBoxLayout, QLabel, QMenu, QShortcut,
39
+ QStyleOptionViewItem, QTreeView, QVBoxLayout, QWidget
39
40
  )
40
41
 
41
42
  from novelwriter import CONFIG, SHARED
42
- from novelwriter.common import minmax
43
- from novelwriter.constants import nwHeaders, nwLabels, nwUnicode, trConst
43
+ from novelwriter.common import qtLambda
44
+ from novelwriter.constants import nwLabels, nwStyles, nwUnicode, trConst
44
45
  from novelwriter.core.coretools import DocDuplicator, DocMerger, DocSplitter
45
46
  from novelwriter.core.item import NWItem
47
+ from novelwriter.core.itemmodel import ProjectModel, ProjectNode
46
48
  from novelwriter.dialogs.docmerge import GuiDocMerge
47
49
  from novelwriter.dialogs.docsplit import GuiDocSplit
48
50
  from novelwriter.dialogs.editlabel import GuiEditLabel
49
51
  from novelwriter.dialogs.projectsettings import GuiProjectSettings
50
- from novelwriter.enum import nwDocMode, nwItemClass, nwItemLayout, nwItemType
52
+ from novelwriter.enum import nwChange, nwDocMode, nwItemClass, nwItemLayout, nwItemType
51
53
  from novelwriter.extensions.modified import NIconToolButton
52
54
  from novelwriter.gui.theme import STYLES_MIN_TOOLBUTTON
53
55
  from novelwriter.types import (
54
- QtAlignLeft, QtAlignRight, QtMouseLeft, QtMouseMiddle, QtSizeExpanding,
55
- QtUserRole
56
+ QtHeaderFixed, QtHeaderStretch, QtHeaderToContents, QtMouseLeft,
57
+ QtMouseMiddle, QtScrollAlwaysOff, QtScrollAsNeeded, QtSizeExpanding
56
58
  )
57
59
 
58
60
  logger = logging.getLogger(__name__)
@@ -64,17 +66,9 @@ class GuiProjectView(QWidget):
64
66
  available are mapped through to the project tree class.
65
67
  """
66
68
 
67
- # Signals triggered when the meta data values of items change
68
- treeItemChanged = pyqtSignal(str)
69
- rootFolderChanged = pyqtSignal(str)
70
- wordCountsChanged = pyqtSignal()
71
-
72
- # Signals for user interaction with the project tree
73
- selectedItemChanged = pyqtSignal(str)
74
69
  openDocumentRequest = pyqtSignal(str, Enum, str, bool)
75
-
76
- # Requests for the main GUI
77
70
  projectSettingsRequest = pyqtSignal(int)
71
+ selectedItemChanged = pyqtSignal(str)
78
72
 
79
73
  def __init__(self, parent: QWidget) -> None:
80
74
  super().__init__(parent=parent)
@@ -97,48 +91,44 @@ class GuiProjectView(QWidget):
97
91
  self.keyMoveUp = QShortcut(self.projTree)
98
92
  self.keyMoveUp.setKey("Ctrl+Up")
99
93
  self.keyMoveUp.setContext(Qt.ShortcutContext.WidgetShortcut)
100
- self.keyMoveUp.activated.connect(lambda: self.projTree.moveTreeItem(-1))
94
+ self.keyMoveUp.activated.connect(self.projTree.moveItemUp)
101
95
 
102
96
  self.keyMoveDn = QShortcut(self.projTree)
103
97
  self.keyMoveDn.setKey("Ctrl+Down")
104
98
  self.keyMoveDn.setContext(Qt.ShortcutContext.WidgetShortcut)
105
- self.keyMoveDn.activated.connect(lambda: self.projTree.moveTreeItem(1))
99
+ self.keyMoveDn.activated.connect(self.projTree.moveItemDown)
106
100
 
107
101
  self.keyGoPrev = QShortcut(self.projTree)
108
102
  self.keyGoPrev.setKey("Alt+Up")
109
103
  self.keyGoPrev.setContext(Qt.ShortcutContext.WidgetShortcut)
110
- self.keyGoPrev.activated.connect(lambda: self.projTree.moveToNextItem(-1))
104
+ self.keyGoPrev.activated.connect(self.projTree.goToSiblingUp)
111
105
 
112
106
  self.keyGoNext = QShortcut(self.projTree)
113
107
  self.keyGoNext.setKey("Alt+Down")
114
108
  self.keyGoNext.setContext(Qt.ShortcutContext.WidgetShortcut)
115
- self.keyGoNext.activated.connect(lambda: self.projTree.moveToNextItem(1))
109
+ self.keyGoNext.activated.connect(self.projTree.goToSiblingDown)
116
110
 
117
111
  self.keyGoUp = QShortcut(self.projTree)
118
112
  self.keyGoUp.setKey("Alt+Left")
119
113
  self.keyGoUp.setContext(Qt.ShortcutContext.WidgetShortcut)
120
- self.keyGoUp.activated.connect(lambda: self.projTree.moveToLevel(-1))
114
+ self.keyGoUp.activated.connect(self.projTree.goToParent)
121
115
 
122
116
  self.keyGoDown = QShortcut(self.projTree)
123
117
  self.keyGoDown.setKey("Alt+Right")
124
118
  self.keyGoDown.setContext(Qt.ShortcutContext.WidgetShortcut)
125
- self.keyGoDown.activated.connect(lambda: self.projTree.moveToLevel(1))
119
+ self.keyGoDown.activated.connect(self.projTree.goToFirstChild)
126
120
 
127
121
  self.keyContext = QShortcut(self.projTree)
128
122
  self.keyContext.setKey("Ctrl+.")
129
123
  self.keyContext.setContext(Qt.ShortcutContext.WidgetShortcut)
130
- self.keyContext.activated.connect(lambda: self.projTree.openContextOnSelected())
124
+ self.keyContext.activated.connect(self.projTree.openContextMenu)
131
125
 
132
126
  # Signals
133
127
  self.selectedItemChanged.connect(self.projBar.treeSelectionChanged)
134
- self.projTree.itemRefreshed.connect(self.projBar.treeItemRefreshed)
135
128
  self.projBar.newDocumentFromTemplate.connect(self.createFileFromTemplate)
136
129
 
137
130
  # Function Mappings
138
- self.emptyTrash = self.projTree.emptyTrash
139
- self.requestDeleteItem = self.projTree.requestDeleteItem
140
131
  self.getSelectedHandle = self.projTree.getSelectedHandle
141
- self.changedSince = self.projTree.changedSince
142
132
 
143
133
  return
144
134
 
@@ -149,7 +139,6 @@ class GuiProjectView(QWidget):
149
139
  def updateTheme(self) -> None:
150
140
  """Update theme elements."""
151
141
  self.projBar.updateTheme()
152
- self.populateTree()
153
142
  return
154
143
 
155
144
  def initSettings(self) -> None:
@@ -166,21 +155,12 @@ class GuiProjectView(QWidget):
166
155
 
167
156
  def openProjectTasks(self) -> None:
168
157
  """Run open project tasks."""
169
- self.populateTree()
158
+ self.projTree.loadModel()
159
+ self.projBar.buildTemplatesMenu()
170
160
  self.projBar.buildQuickLinksMenu()
171
161
  self.projBar.setEnabled(True)
172
162
  return
173
163
 
174
- def saveProjectTasks(self) -> None:
175
- """Run save project tasks."""
176
- self.projTree.saveTreeOrder()
177
- return
178
-
179
- def populateTree(self) -> None:
180
- """Build the tree structure from project data."""
181
- self.projTree.buildTree()
182
- return
183
-
184
164
  def setTreeFocus(self) -> None:
185
165
  """Forward the set focus call to the tree widget."""
186
166
  self.projTree.setFocus()
@@ -190,10 +170,20 @@ class GuiProjectView(QWidget):
190
170
  """Check if the project tree has focus."""
191
171
  return self.projTree.hasFocus()
192
172
 
173
+ def connectMenuActions(self, rename: QAction, delete: QAction, trash: QAction) -> None:
174
+ """Main menu actions passed to the project tree."""
175
+ self.projTree.addAction(rename)
176
+ self.projTree.addAction(delete)
177
+ self.projTree.addAction(trash)
178
+ rename.triggered.connect(self.renameTreeItem)
179
+ delete.triggered.connect(self.projTree.processDeleteRequest)
180
+ return
181
+
193
182
  ##
194
183
  # Public Slots
195
184
  ##
196
185
 
186
+ @pyqtSlot()
197
187
  @pyqtSlot(str, str)
198
188
  def renameTreeItem(self, tHandle: str | None = None, name: str = "") -> None:
199
189
  """External request to rename an item or the currently selected
@@ -201,8 +191,11 @@ class GuiProjectView(QWidget):
201
191
  """
202
192
  if tHandle is None:
203
193
  tHandle = self.projTree.getSelectedHandle()
204
- if tHandle:
205
- self.projTree.renameTreeItem(tHandle, name=name)
194
+ if nwItem := SHARED.project.tree[tHandle]:
195
+ newLabel, dlgOk = GuiEditLabel.getLabel(self, text=name or nwItem.itemName)
196
+ if dlgOk:
197
+ nwItem.setName(newLabel)
198
+ nwItem.notifyToRefresh()
206
199
  return
207
200
 
208
201
  @pyqtSlot(str, bool)
@@ -212,10 +205,15 @@ class GuiProjectView(QWidget):
212
205
  return
213
206
 
214
207
  @pyqtSlot(str)
215
- def updateItemValues(self, tHandle: str) -> None:
216
- """Update tree item."""
217
- if nwItem := SHARED.project.tree[tHandle]:
218
- self.projTree.setTreeItemValues(nwItem)
208
+ def setActiveHandle(self, tHandle: str | None) -> None:
209
+ """Highlight the active handle."""
210
+ self.projTree.setActiveHandle(tHandle)
211
+ return
212
+
213
+ @pyqtSlot(str, Enum)
214
+ def onProjectItemChanged(self, tHandle: str, change: nwChange) -> None:
215
+ """Refresh other content when project item changed."""
216
+ self.projBar.processTemplateDocuments(tHandle)
219
217
  return
220
218
 
221
219
  @pyqtSlot(str)
@@ -225,31 +223,12 @@ class GuiProjectView(QWidget):
225
223
  self.projTree.newTreeItem(nwItemType.FILE, copyDoc=tHandle)
226
224
  return
227
225
 
228
- @pyqtSlot(str, int, int, int)
229
- def updateCounts(self, tHandle: str, cCount: int, wCount: int, pCount: int) -> None:
230
- """Slot for updating the word count of a specific item."""
231
- self.projTree.propagateCount(tHandle, wCount, countChildren=True)
232
- self.wordCountsChanged.emit()
233
- return
234
-
235
- @pyqtSlot(str)
236
- def updateRootItem(self, tHandle: str) -> None:
226
+ @pyqtSlot(str, Enum)
227
+ def updateRootItem(self, tHandle: str, change: nwChange) -> None:
237
228
  """Process root item changes."""
238
229
  self.projBar.buildQuickLinksMenu()
239
230
  return
240
231
 
241
- @pyqtSlot(str, nwItemClass)
242
- def createNewNote(self, tag: str, itemClass: nwItemClass) -> None:
243
- """Process new not request."""
244
- self.projTree.createNewNote(tag, itemClass)
245
- return
246
-
247
- @pyqtSlot(str)
248
- def refreshUserLabels(self, kind: str) -> None:
249
- """Refresh status or importance labels."""
250
- self.projTree.refreshUserLabels(kind)
251
- return
252
-
253
232
 
254
233
  class GuiProjectToolBar(QWidget):
255
234
 
@@ -286,38 +265,38 @@ class GuiProjectToolBar(QWidget):
286
265
  # Move Buttons
287
266
  self.tbMoveU = NIconToolButton(self, iSz)
288
267
  self.tbMoveU.setToolTip("%s [Ctrl+Up]" % self.tr("Move Up"))
289
- self.tbMoveU.clicked.connect(lambda: self.projTree.moveTreeItem(-1))
268
+ self.tbMoveU.clicked.connect(self.projTree.moveItemUp)
290
269
 
291
270
  self.tbMoveD = NIconToolButton(self, iSz)
292
271
  self.tbMoveD.setToolTip("%s [Ctrl+Down]" % self.tr("Move Down"))
293
- self.tbMoveD.clicked.connect(lambda: self.projTree.moveTreeItem(1))
272
+ self.tbMoveD.clicked.connect(self.projTree.moveItemDown)
294
273
 
295
274
  # Add Item Menu
296
275
  self.mAdd = QMenu(self)
297
276
 
298
277
  self.aAddEmpty = self.mAdd.addAction(trConst(nwLabels.ITEM_DESCRIPTION["document"]))
299
278
  self.aAddEmpty.triggered.connect(
300
- lambda: self.projTree.newTreeItem(nwItemType.FILE, hLevel=0, isNote=False)
279
+ qtLambda(self.projTree.newTreeItem, nwItemType.FILE, hLevel=0, isNote=False)
301
280
  )
302
281
 
303
282
  self.aAddChap = self.mAdd.addAction(trConst(nwLabels.ITEM_DESCRIPTION["doc_h2"]))
304
283
  self.aAddChap.triggered.connect(
305
- lambda: self.projTree.newTreeItem(nwItemType.FILE, hLevel=2, isNote=False)
284
+ qtLambda(self.projTree.newTreeItem, nwItemType.FILE, hLevel=2, isNote=False)
306
285
  )
307
286
 
308
287
  self.aAddScene = self.mAdd.addAction(trConst(nwLabels.ITEM_DESCRIPTION["doc_h3"]))
309
288
  self.aAddScene.triggered.connect(
310
- lambda: self.projTree.newTreeItem(nwItemType.FILE, hLevel=3, isNote=False)
289
+ qtLambda(self.projTree.newTreeItem, nwItemType.FILE, hLevel=3, isNote=False)
311
290
  )
312
291
 
313
292
  self.aAddNote = self.mAdd.addAction(trConst(nwLabels.ITEM_DESCRIPTION["note"]))
314
293
  self.aAddNote.triggered.connect(
315
- lambda: self.projTree.newTreeItem(nwItemType.FILE, hLevel=1, isNote=True)
294
+ qtLambda(self.projTree.newTreeItem, nwItemType.FILE, hLevel=1, isNote=True)
316
295
  )
317
296
 
318
297
  self.aAddFolder = self.mAdd.addAction(trConst(nwLabels.ITEM_DESCRIPTION["folder"]))
319
298
  self.aAddFolder.triggered.connect(
320
- lambda: self.projTree.newTreeItem(nwItemType.FOLDER)
299
+ qtLambda(self.projTree.newTreeItem, nwItemType.FOLDER)
321
300
  )
322
301
 
323
302
  self.mTemplates = _UpdatableMenu(self.mAdd)
@@ -337,13 +316,13 @@ class GuiProjectToolBar(QWidget):
337
316
  self.mMore = QMenu(self)
338
317
 
339
318
  self.aExpand = self.mMore.addAction(self.tr("Expand All"))
340
- self.aExpand.triggered.connect(lambda: self.projTree.setExpandedFromHandle(None, True))
319
+ self.aExpand.triggered.connect(self.projTree.expandAll)
341
320
 
342
321
  self.aCollapse = self.mMore.addAction(self.tr("Collapse All"))
343
- self.aCollapse.triggered.connect(lambda: self.projTree.setExpandedFromHandle(None, False))
322
+ self.aCollapse.triggered.connect(self.projTree.collapseAll)
344
323
 
345
324
  self.aEmptyTrash = self.mMore.addAction(self.tr("Empty Trash"))
346
- self.aEmptyTrash.triggered.connect(lambda: self.projTree.emptyTrash())
325
+ self.aEmptyTrash.triggered.connect(self.projTree.emptyTrash)
347
326
 
348
327
  self.tbMore = NIconToolButton(self, iSz)
349
328
  self.tbMore.setToolTip(self.tr("More Options"))
@@ -396,6 +375,7 @@ class GuiProjectToolBar(QWidget):
396
375
  self.aAddNote.setIcon(SHARED.theme.getIcon("proj_note"))
397
376
  self.aAddFolder.setIcon(SHARED.theme.getIcon("proj_folder"))
398
377
 
378
+ self.buildTemplatesMenu()
399
379
  self.buildQuickLinksMenu()
400
380
  self._buildRootMenu()
401
381
 
@@ -416,23 +396,30 @@ class GuiProjectToolBar(QWidget):
416
396
  action.setData(tHandle)
417
397
  action.setIcon(SHARED.theme.getIcon(nwLabels.CLASS_ICON[nwItem.itemClass]))
418
398
  action.triggered.connect(
419
- lambda _, tHandle=tHandle: self.projView.setSelectedHandle(tHandle, doScroll=True)
399
+ qtLambda(self.projView.setSelectedHandle, tHandle, doScroll=True)
420
400
  )
421
401
  return
422
402
 
423
- ##
424
- # Public Slots
425
- ##
403
+ def buildTemplatesMenu(self) -> None:
404
+ """Build the templates menu."""
405
+ for tHandle, _ in SHARED.project.tree.iterRoots(nwItemClass.TEMPLATE):
406
+ for dHandle in SHARED.project.tree.subTree(tHandle):
407
+ self.processTemplateDocuments(dHandle)
408
+ return
426
409
 
427
- @pyqtSlot(str, NWItem, QIcon)
428
- def treeItemRefreshed(self, tHandle: str, nwItem: NWItem, icon: QIcon) -> None:
410
+ def processTemplateDocuments(self, tHandle: str) -> None:
429
411
  """Process change in tree items to update menu content."""
430
- if nwItem.isTemplateFile() and nwItem.isActive:
431
- self.mTemplates.addUpdate(tHandle, nwItem.itemName, icon)
432
- elif tHandle in self.mTemplates:
433
- self.mTemplates.remove(tHandle)
412
+ if item := SHARED.project.tree[tHandle]:
413
+ if item.isTemplateFile() and item.isActive:
414
+ self.mTemplates.addUpdate(tHandle, item.itemName, item.getMainIcon())
415
+ elif tHandle in self.mTemplates:
416
+ self.mTemplates.remove(tHandle)
434
417
  return
435
418
 
419
+ ##
420
+ # Public Slots
421
+ ##
422
+
436
423
  @pyqtSlot(str)
437
424
  def treeSelectionChanged(self, tHandle: str) -> None:
438
425
  """Toggle the visibility of the new item entries for novel
@@ -455,7 +442,9 @@ class GuiProjectToolBar(QWidget):
455
442
  def addClass(itemClass: nwItemClass) -> None:
456
443
  aNew = self.mAddRoot.addAction(trConst(nwLabels.CLASS_NAME[itemClass]))
457
444
  aNew.setIcon(SHARED.theme.getIcon(nwLabels.CLASS_ICON[itemClass]))
458
- aNew.triggered.connect(lambda: self.projTree.newTreeItem(nwItemType.ROOT, itemClass))
445
+ aNew.triggered.connect(
446
+ qtLambda(self.projTree.newTreeItem, nwItemType.ROOT, itemClass)
447
+ )
459
448
  self.mAddRoot.addAction(aNew)
460
449
  return
461
450
 
@@ -476,18 +465,7 @@ class GuiProjectToolBar(QWidget):
476
465
  return
477
466
 
478
467
 
479
- class GuiProjectTree(QTreeWidget):
480
-
481
- C_DATA = 0
482
- C_NAME = 0
483
- C_COUNT = 1
484
- C_ACTIVE = 2
485
- C_STATUS = 3
486
-
487
- D_HANDLE = QtUserRole
488
- D_WORDS = QtUserRole + 1
489
-
490
- itemRefreshed = pyqtSignal(str, NWItem, QIcon)
468
+ class GuiProjectTree(QTreeView):
491
469
 
492
470
  def __init__(self, projView: GuiProjectView) -> None:
493
471
  super().__init__(parent=projView)
@@ -497,25 +475,14 @@ class GuiProjectTree(QTreeWidget):
497
475
  self.projView = projView
498
476
 
499
477
  # Internal Variables
500
- self._treeMap: dict[str, QTreeWidgetItem] = {}
501
- self._timeChanged = 0.0
502
- self._popAlert = None
478
+ self._actHandle = None
503
479
 
504
480
  # Cached Translations
505
- self.trActive = self.tr("Active")
506
- self.trInactive = self.tr("Inactive")
507
- self.trPermDelete = self.tr("Permanently delete {0} file(s) from Trash?")
508
-
509
- # Build GUI
510
- # =========
511
-
512
- # Context Menu
513
- self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
514
- self.customContextMenuRequested.connect(self._openContextMenu)
481
+ self.trActive = trConst(nwLabels.ACTIVE_NAME["checked"])
482
+ self.trInactive = trConst(nwLabels.ACTIVE_NAME["unchecked"])
515
483
 
516
484
  # Tree Settings
517
485
  iPx = SHARED.theme.baseIconHeight
518
- cMg = CONFIG.pxInt(6)
519
486
 
520
487
  self.setIconSize(SHARED.theme.baseIconSize)
521
488
  self.setFrameStyle(QFrame.Shape.NoFrame)
@@ -525,47 +492,24 @@ class GuiProjectTree(QTreeWidget):
525
492
  self.setAutoExpandDelay(1000)
526
493
  self.setHeaderHidden(True)
527
494
  self.setIndentation(iPx)
528
- self.setColumnCount(4)
529
-
530
- # Lock the column sizes
531
- treeHeader = self.header()
532
- treeHeader.setStretchLastSection(False)
533
- treeHeader.setMinimumSectionSize(iPx + cMg)
534
- treeHeader.setSectionResizeMode(self.C_NAME, QHeaderView.ResizeMode.Stretch)
535
- treeHeader.setSectionResizeMode(self.C_COUNT, QHeaderView.ResizeMode.ResizeToContents)
536
- treeHeader.setSectionResizeMode(self.C_ACTIVE, QHeaderView.ResizeMode.Fixed)
537
- treeHeader.setSectionResizeMode(self.C_STATUS, QHeaderView.ResizeMode.Fixed)
538
- treeHeader.resizeSection(self.C_ACTIVE, iPx + cMg)
539
- treeHeader.resizeSection(self.C_STATUS, iPx + cMg)
540
495
 
541
496
  # Allow Move by Drag & Drop
542
497
  self.setDragEnabled(True)
543
498
  self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
544
499
 
545
- # Disable built-in auto scroll as it isn't working in some Qt
546
- # releases (see #1561) and instead use our own implementation
547
- self.setAutoScroll(False)
548
-
549
- # But don't allow drop on root level
550
- # Due to a bug, this stops working somewhere between Qt 5.15.3
551
- # and 5.15.8, so this is also blocked in dropEvent (see #1569)
552
- trRoot = self.invisibleRootItem()
553
- trRoot.setFlags(trRoot.flags() ^ Qt.ItemFlag.ItemIsDropEnabled)
554
-
555
500
  # Set selection options
556
501
  self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
557
502
  self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
558
503
 
559
- # Connect signals
560
- self.itemDoubleClicked.connect(self._treeDoubleClick)
561
- self.itemSelectionChanged.connect(self._treeSelectionChange)
504
+ # Context Menu
505
+ self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
506
+ self.customContextMenuRequested.connect(self.openContextMenu)
562
507
 
563
- # Auto Scroll
564
- self._scrollMargin = SHARED.theme.baseIconHeight
565
- self._scrollDirection = 0
566
- self._scrollTimer = QTimer(self)
567
- self._scrollTimer.timeout.connect(self._doAutoScroll)
568
- self._scrollTimer.setInterval(250)
508
+ # Connect signals
509
+ self.clicked.connect(self._onSingleClick)
510
+ self.doubleClicked.connect(self._onDoubleClick)
511
+ self.collapsed.connect(self._onNodeCollapsed)
512
+ self.expanded.connect(self._onNodeExpanded)
569
513
 
570
514
  # Set custom settings
571
515
  self.initSettings()
@@ -578,41 +522,83 @@ class GuiProjectTree(QTreeWidget):
578
522
  """Set or update tree widget settings."""
579
523
  # Scroll bars
580
524
  if CONFIG.hideVScroll:
581
- self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
525
+ self.setVerticalScrollBarPolicy(QtScrollAlwaysOff)
582
526
  else:
583
- self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
527
+ self.setVerticalScrollBarPolicy(QtScrollAsNeeded)
584
528
  if CONFIG.hideHScroll:
585
- self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
529
+ self.setHorizontalScrollBarPolicy(QtScrollAlwaysOff)
586
530
  else:
587
- self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
531
+ self.setHorizontalScrollBarPolicy(QtScrollAsNeeded)
532
+ return
533
+
534
+ ##
535
+ # External Methods
536
+ ##
537
+
538
+ def setActiveHandle(self, tHandle: str | None) -> None:
539
+ """Set the handle to be highlighted."""
540
+ self._actHandle = tHandle
588
541
  return
589
542
 
543
+ def getSelectedHandle(self) -> str | None:
544
+ """Get the currently selected handle."""
545
+ if (indexes := self.selectedIndexes()) and (node := self._getNode(indexes[0])):
546
+ return node.item.itemHandle
547
+ return None
548
+
590
549
  ##
591
- # Class Methods
550
+ # Module Internal Methods
592
551
  ##
593
552
 
594
553
  def clearTree(self) -> None:
595
- """Clear the GUI content and the related map."""
596
- self.clear()
597
- self._treeMap = {}
598
- self._timeChanged = 0.0
554
+ """Clear the tree view."""
555
+ self.setModel(None)
599
556
  return
600
557
 
601
- def createNewNote(self, tag: str, itemClass: nwItemClass) -> None:
602
- """Create a new note. This function is used by the document
603
- editor to create note files for unknown tags.
604
- """
605
- if itemClass != nwItemClass.NO_CLASS:
606
- if not (rHandle := SHARED.project.tree.findRoot(itemClass)):
607
- self.newTreeItem(nwItemType.ROOT, itemClass)
608
- rHandle = SHARED.project.tree.findRoot(itemClass)
609
- if rHandle and (tHandle := SHARED.project.newFile(tag, rHandle)):
610
- SHARED.project.writeNewFile(tHandle, 1, False, f"@tag: {tag}\n\n")
611
- self.revealNewTreeItem(tHandle, wordCount=True)
558
+ def loadModel(self) -> None:
559
+ """Load and prepare a new project model."""
560
+ self.setModel(SHARED.project.tree.model)
561
+
562
+ # Lock the column sizes
563
+ iPx = SHARED.theme.baseIconHeight
564
+ cMg = CONFIG.pxInt(6)
565
+
566
+ treeHeader = self.header()
567
+ treeHeader.setStretchLastSection(False)
568
+ treeHeader.setMinimumSectionSize(iPx + cMg)
569
+ treeHeader.setSectionResizeMode(ProjectNode.C_NAME, QtHeaderStretch)
570
+ treeHeader.setSectionResizeMode(ProjectNode.C_COUNT, QtHeaderToContents)
571
+ treeHeader.setSectionResizeMode(ProjectNode.C_ACTIVE, QtHeaderFixed)
572
+ treeHeader.setSectionResizeMode(ProjectNode.C_STATUS, QtHeaderFixed)
573
+ treeHeader.resizeSection(ProjectNode.C_ACTIVE, iPx + cMg)
574
+ treeHeader.resizeSection(ProjectNode.C_STATUS, iPx + cMg)
575
+
576
+ self.restoreExpandedState()
577
+
578
+ return
579
+
580
+ def restoreExpandedState(self) -> None:
581
+ """Expand all nodes that were previously expanded."""
582
+ if model := self._getModel():
583
+ self.blockSignals(True)
584
+ for index in model.allExpanded():
585
+ self.setExpanded(index, True)
586
+ self.blockSignals(False)
612
587
  return
613
588
 
614
- def newTreeItem(self, itemType: nwItemType, itemClass: nwItemClass | None = None,
615
- hLevel: int = 1, isNote: bool = False, copyDoc: str | None = None) -> bool:
589
+ def setSelectedHandle(self, tHandle: str | None, doScroll: bool = False) -> None:
590
+ """Set a specific handle as the selected item."""
591
+ if (model := self._getModel()) and (index := model.indexFromHandle(tHandle)).isValid():
592
+ self.setCurrentIndex(index)
593
+ if doScroll:
594
+ self.scrollTo(index, QAbstractItemView.ScrollHint.PositionAtCenter)
595
+ self.projView.selectedItemChanged.emit(tHandle)
596
+ return
597
+
598
+ def newTreeItem(
599
+ self, itemType: nwItemType, itemClass: nwItemClass | None = None,
600
+ hLevel: int = 1, isNote: bool = False, copyDoc: str | None = None,
601
+ ) -> None:
616
602
  """Add new item to the tree, with a given itemType (and
617
603
  itemClass if Root), and attach it to the selected handle. Also
618
604
  make sure the item is added in a place it can be added, and that
@@ -620,1043 +606,438 @@ class GuiProjectTree(QTreeWidget):
620
606
  """
621
607
  if not SHARED.hasProject:
622
608
  logger.error("No project open")
623
- return False
609
+ return
624
610
 
625
- nHandle = None
626
611
  tHandle = None
627
-
628
612
  if itemType == nwItemType.ROOT and isinstance(itemClass, nwItemClass):
629
613
 
630
- tHandle = SHARED.project.newRoot(itemClass)
631
- sHandle = self.getSelectedHandle()
632
- pItem = SHARED.project.tree[sHandle] if sHandle else None
633
- nHandle = pItem.itemRoot if pItem else None
614
+ pos = -1
615
+ if (node := self._getNode(self.currentIndex())) and (itemRoot := node.item.itemRoot):
616
+ if root := SHARED.project.tree.nodes.get(itemRoot):
617
+ pos = root.row() + 1
618
+
619
+ tHandle = SHARED.project.newRoot(itemClass, pos)
620
+ self.restoreExpandedState()
634
621
 
635
622
  elif itemType in (nwItemType.FILE, nwItemType.FOLDER):
636
623
 
637
- sHandle = self.getSelectedHandle()
638
- pItem = SHARED.project.tree[sHandle] if sHandle else None
639
- if sHandle is None or pItem is None:
624
+ if not ((model := self._getModel()) and (node := model.node(self.currentIndex()))):
640
625
  SHARED.error(self.tr("Did not find anywhere to add the file or folder!"))
641
- return False
642
-
643
- # Collect some information about the selected item
644
- qItem = self._getTreeItem(sHandle)
645
- sLevel = nwHeaders.H_LEVEL.get(pItem.mainHeading, 0)
646
- sIsParent = False if qItem is None else qItem.childCount() > 0
626
+ return
647
627
 
648
- if SHARED.project.tree.isTrash(sHandle):
628
+ if node.item.itemClass == nwItemClass.TRASH:
649
629
  SHARED.error(self.tr("Cannot add new files or folders to the Trash folder."))
650
- return False
630
+ return
631
+
632
+ # Collect some information about the selected item
633
+ sLevel = nwStyles.H_LEVEL.get(node.item.mainHeading, 0)
634
+ sIsParent = node.childCount() > 0
651
635
 
652
636
  # Set default label and determine if new item is to be added
653
637
  # as child or sibling to the selected item
654
638
  if itemType == nwItemType.FILE:
655
639
  if copyDoc and (cItem := SHARED.project.tree[copyDoc]):
656
640
  newLabel = cItem.itemName
657
- asChild = sIsParent and pItem.isDocumentLayout()
641
+ asChild = sIsParent and node.item.isDocumentLayout()
658
642
  elif isNote:
659
643
  newLabel = self.tr("New Note")
660
644
  asChild = sIsParent
661
645
  elif hLevel == 2:
662
646
  newLabel = self.tr("New Chapter")
663
- asChild = sIsParent and pItem.isDocumentLayout() and sLevel < 2
647
+ asChild = sIsParent and node.item.isDocumentLayout() and sLevel < 2
664
648
  elif hLevel == 3:
665
649
  newLabel = self.tr("New Scene")
666
- asChild = sIsParent and pItem.isDocumentLayout() and sLevel < 3
650
+ asChild = sIsParent and node.item.isDocumentLayout() and sLevel < 3
667
651
  else:
668
652
  newLabel = self.tr("New Document")
669
- asChild = sIsParent and pItem.isDocumentLayout()
653
+ asChild = sIsParent and node.item.isDocumentLayout()
670
654
  else:
671
655
  newLabel = self.tr("New Folder")
672
656
  asChild = False
673
657
 
674
- if not (asChild or pItem.isFolderType() or pItem.isRootType()):
675
- # Move to the parent item so that the new item is added
676
- # as a sibling instead
677
- nHandle = sHandle
678
- sHandle = pItem.itemParent
679
- if sHandle is None:
680
- # Bug: We have a condition that is unhandled
681
- logger.error("Internal error")
682
- return False
683
-
684
- # Ask for label
685
- newLabel, dlgOk = GuiEditLabel.getLabel(self, text=newLabel)
686
- if not dlgOk:
687
- logger.info("New item creation cancelled by user")
688
- return False
689
-
690
- # Add the file or folder
691
- if itemType == nwItemType.FILE:
692
- tHandle = SHARED.project.newFile(newLabel, sHandle)
693
- else:
694
- tHandle = SHARED.project.newFolder(newLabel, sHandle)
695
-
696
- else:
697
- logger.error("Failed to add new item")
698
- return False
699
-
700
- # If there is no handle set, return here. This is a bug.
701
- if tHandle is None: # pragma: no cover
702
- logger.error("Internal error")
703
- return True
658
+ pos = -1
659
+ sHandle = None
660
+ if not (asChild or node.item.isFolderType() or node.item.isRootType()):
661
+ pos = node.row() + 1
662
+ sHandle = node.item.itemParent
704
663
 
705
- # Handle new file creation
706
- if itemType == nwItemType.FILE and copyDoc:
707
- SHARED.project.copyFileContent(tHandle, copyDoc)
708
- elif itemType == nwItemType.FILE and hLevel > 0:
709
- SHARED.project.writeNewFile(tHandle, hLevel, not isNote)
664
+ sHandle = sHandle or node.item.itemHandle
665
+ newLabel, dlgOk = GuiEditLabel.getLabel(self, text=newLabel)
666
+ if dlgOk:
667
+ # Add the file or folder
668
+ if itemType == nwItemType.FILE:
669
+ if tHandle := SHARED.project.newFile(newLabel, sHandle, pos):
670
+ if copyDoc:
671
+ SHARED.project.copyFileContent(tHandle, copyDoc)
672
+ elif hLevel > 0:
673
+ SHARED.project.writeNewFile(tHandle, hLevel, not isNote)
674
+ SHARED.project.index.reIndexHandle(tHandle)
675
+ SHARED.project.tree.refreshItems([tHandle])
676
+ else:
677
+ tHandle = SHARED.project.newFolder(newLabel, sHandle, pos)
710
678
 
711
- # Add the new item to the project tree
712
- self.revealNewTreeItem(tHandle, nHandle=nHandle, wordCount=True)
713
- self.projView.setTreeFocus() # See issue #1376
679
+ # Select the new item automatically
680
+ if tHandle:
681
+ self.setSelectedHandle(tHandle)
714
682
 
715
- return True
683
+ return
716
684
 
717
- def revealNewTreeItem(self, tHandle: str | None, nHandle: str | None = None,
718
- wordCount: bool = False) -> bool:
719
- """Reveal a newly added project item in the project tree."""
720
- nwItem = SHARED.project.tree[tHandle] if tHandle else None
721
- if tHandle is None or nwItem is None:
722
- return False
685
+ def mergeDocuments(self, tHandle: str, newFile: bool) -> bool:
686
+ """Merge an item's child documents into a single document."""
687
+ logger.info("Request to merge items under handle '%s'", tHandle)
723
688
 
724
- trItem = self._addTreeItem(nwItem, nHandle)
725
- if trItem is None:
689
+ if not (tItem := SHARED.project.tree[tHandle]):
726
690
  return False
727
691
 
728
- if nwItem.isFileType() and wordCount:
729
- wC = SHARED.project.index.getCounts(tHandle)[1]
730
- self.propagateCount(tHandle, wC)
731
- self.projView.wordCountsChanged.emit()
732
-
733
- pHandle = nwItem.itemParent
734
- if pHandle is not None and pHandle in self._treeMap:
735
- self._treeMap[pHandle].setExpanded(True)
736
-
737
- self._alertTreeChange(tHandle, flush=True)
738
- self.setCurrentItem(trItem)
739
-
740
- return True
741
-
742
- def moveTreeItem(self, step: int) -> bool:
743
- """Move an item up or down in the tree."""
744
- tHandle = self.getSelectedHandle()
745
- tItem = self._getTreeItem(tHandle)
746
- if tItem is None:
747
- logger.debug("No item selected")
692
+ if tItem.isRootType():
693
+ logger.error("Cannot merge root item")
748
694
  return False
749
695
 
750
- pItem = tItem.parent()
751
- isExp = tItem.isExpanded()
752
- if pItem is None:
753
- tIndex = self.indexOfTopLevelItem(tItem)
754
- nChild = self.topLevelItemCount()
755
-
756
- nIndex = tIndex + step
757
- if nIndex < 0 or nIndex >= nChild:
758
- return False
759
-
760
- cItem = self.takeTopLevelItem(tIndex)
761
- self.insertTopLevelItem(nIndex, cItem)
762
-
763
- else:
764
- tIndex = pItem.indexOfChild(tItem)
765
- nChild = pItem.childCount()
766
-
767
- nIndex = tIndex + step
768
- if nIndex < 0 or nIndex >= nChild:
769
- return False
770
-
771
- cItem = pItem.takeChild(tIndex)
772
- pItem.insertChild(nIndex, cItem)
773
-
774
- self._alertTreeChange(tHandle, flush=True)
775
- self.setCurrentItem(tItem)
776
- tItem.setExpanded(isExp)
696
+ itemList = SHARED.project.tree.subTree(tHandle)
697
+ if newFile:
698
+ itemList.insert(0, tHandle)
777
699
 
778
- return True
779
-
780
- def moveToNextItem(self, step: int) -> None:
781
- """Move to the next item of the same tree level."""
782
- tHandle = self.getSelectedHandle()
783
- tItem = self._getTreeItem(tHandle) if tHandle else None
784
- if tItem:
785
- pItem = tItem.parent() or self.invisibleRootItem()
786
- next = minmax(pItem.indexOfChild(tItem) + step, 0, pItem.childCount() - 1)
787
- self.setCurrentItem(pItem.child(next))
788
- return
789
-
790
- def moveToLevel(self, step: int) -> None:
791
- """Move to the next item in the parent/child chain."""
792
- tHandle = self.getSelectedHandle()
793
- tItem = self._getTreeItem(tHandle) if tHandle else None
794
- if tItem:
795
- if step < 0 and tItem.parent():
796
- self.setCurrentItem(tItem.parent())
797
- elif step > 0 and tItem.childCount() > 0:
798
- self.setCurrentItem(tItem.child(0))
799
- return
800
-
801
- def renameTreeItem(self, tHandle: str, name: str = "") -> None:
802
- """Open a dialog to edit the label of an item."""
803
- if nwItem := SHARED.project.tree[tHandle]:
804
- newLabel, dlgOk = GuiEditLabel.getLabel(self, text=name or nwItem.itemName)
805
- if dlgOk:
806
- nwItem.setName(newLabel)
807
- self.setTreeItemValues(nwItem)
808
- self._alertTreeChange(tHandle, flush=False)
809
- return
810
-
811
- def saveTreeOrder(self) -> None:
812
- """Build a list of the items in the project tree and send them
813
- to the project class. This syncs up the two versions of the
814
- project structure, and must be called before any code that
815
- depends on this order to be up to date.
816
- """
817
- items = []
818
- for i in range(self.topLevelItemCount()):
819
- item = self.topLevelItem(i)
820
- if isinstance(item, QTreeWidgetItem):
821
- items = self._scanChildren(items, item, i)
822
- logger.debug("Saving project tree item order")
823
- SHARED.project.setTreeOrder(items)
824
- return
825
-
826
- def getTreeFromHandle(self, tHandle: str) -> list[str]:
827
- """Recursively return all the child items starting from a given
828
- item handle.
829
- """
830
- result = []
831
- tIten = self._getTreeItem(tHandle)
832
- if tIten is not None:
833
- result = self._scanChildren(result, tIten, 0)
834
- return result
835
-
836
- def requestDeleteItem(self, tHandle: str | None = None) -> bool:
837
- """Request an item deleted from the project tree. This function
838
- can be called on any item, and will check whether to attempt a
839
- permanent deletion or moving the item to Trash.
840
- """
841
- if not SHARED.hasProject:
842
- logger.error("No project open")
700
+ data, status = GuiDocMerge.getData(SHARED.mainGui, tHandle, itemList)
701
+ if not status:
702
+ logger.info("Action cancelled by user")
843
703
  return False
844
-
845
- if not self.hasFocus():
846
- logger.info("Delete action blocked due to no widget focus")
704
+ if not (items := data.get("finalItems", [])):
705
+ SHARED.info(self.tr("No documents selected for merging."))
847
706
  return False
848
707
 
849
- if tHandle is None:
850
- tHandle = self.getSelectedHandle()
851
-
852
- if tHandle is None:
853
- logger.error("There is no item to delete")
854
- return False
855
-
856
- trashHandle = SHARED.project.tree.trashRoot
857
- if tHandle == trashHandle:
858
- logger.error("Cannot delete the Trash folder")
859
- return False
708
+ # Save the open document first, in case it's part of merge
709
+ SHARED.saveEditor()
860
710
 
861
- nwItem = SHARED.project.tree[tHandle]
862
- if nwItem is None:
863
- return False
711
+ # Create merge object, and append docs
712
+ docMerger = DocMerger(SHARED.project)
713
+ mLabel = self.tr("Merged")
864
714
 
865
- if SHARED.project.tree.isTrash(tHandle) or nwItem.isRootType():
866
- status = self.permDeleteItem(tHandle)
715
+ if newFile:
716
+ docMerger.newTargetDoc(tHandle, f"[{mLabel}] {tItem.itemName}")
717
+ elif tItem.isFileType():
718
+ docMerger.setTargetDoc(tHandle)
867
719
  else:
868
- status = self.moveItemToTrash(tHandle)
869
-
870
- return status
871
-
872
- @pyqtSlot()
873
- def emptyTrash(self) -> bool:
874
- """Permanently delete all documents in the Trash folder. This
875
- function only asks for confirmation once, and calls the regular
876
- deleteItem function for each document in the Trash folder.
877
- """
878
- if not SHARED.hasProject:
879
- logger.error("No project open")
880
- return False
881
-
882
- trashHandle = SHARED.project.tree.trashRoot
883
-
884
- logger.debug("Emptying Trash folder")
885
- if trashHandle is None:
886
- SHARED.info(self.tr("There is currently no Trash folder in this project."))
887
720
  return False
888
721
 
889
- trashItems = self.getTreeFromHandle(trashHandle)
890
- if trashHandle in trashItems:
891
- trashItems.remove(trashHandle)
892
-
893
- nTrash = len(trashItems)
894
- if nTrash == 0:
895
- SHARED.info(self.tr("The Trash folder is already empty."))
896
- return False
722
+ for sHandle in items:
723
+ docMerger.appendText(sHandle, True, mLabel)
897
724
 
898
- if not SHARED.question(self.trPermDelete.format(nTrash)):
899
- logger.info("Action cancelled by user")
725
+ if not docMerger.writeTargetDoc():
726
+ SHARED.error(
727
+ self.tr("Could not write document content."),
728
+ info=docMerger.getError()
729
+ )
900
730
  return False
901
731
 
902
- logger.debug("Deleting %d file(s) from Trash", nTrash)
903
- for tHandle in reversed(self.getTreeFromHandle(trashHandle)):
904
- if tHandle == trashHandle:
905
- continue
906
- self.permDeleteItem(tHandle, askFirst=False, flush=False)
732
+ if data.get("moveToTrash", False):
733
+ self.processDeleteRequest(data.get("finalItems", []), False)
907
734
 
908
- if nTrash > 0:
909
- self._alertTreeChange(trashHandle, flush=True)
735
+ if mHandle := docMerger.targetHandle:
736
+ self.projView.openDocumentRequest.emit(mHandle, nwDocMode.EDIT, "", False)
737
+ self.projView.setSelectedHandle(mHandle, doScroll=True)
910
738
 
911
739
  return True
912
740
 
913
- def moveItemToTrash(self, tHandle: str, askFirst: bool = True, flush: bool = True) -> bool:
914
- """Move an item to Trash. Root folders cannot be moved to Trash,
915
- so such a request is cancelled.
916
- """
917
- trItemS = self._getTreeItem(tHandle)
918
- nwItemS = SHARED.project.tree[tHandle]
919
-
920
- if trItemS is None or nwItemS is None:
921
- logger.error("Could not find tree item for deletion")
922
- return False
923
-
924
- if SHARED.project.tree.isTrash(tHandle):
925
- logger.error("Item is already in the Trash folder")
926
- return False
741
+ def splitDocument(self, tHandle: str) -> bool:
742
+ """Split a document into multiple documents."""
743
+ logger.info("Request to split items with handle '%s'", tHandle)
927
744
 
928
- if nwItemS.isRootType():
929
- logger.error("Root folders cannot be moved to Trash")
745
+ if not (tItem := SHARED.project.tree[tHandle]):
930
746
  return False
931
747
 
932
- logger.debug("User requested file or folder '%s' move to Trash", tHandle)
933
-
934
- trItemP = trItemS.parent()
935
- trItemT = self._addTrashRoot()
936
- if trItemP is None or trItemT is None:
937
- logger.error("Could not delete item")
748
+ if not tItem.isFileType() or tItem.itemParent is None:
749
+ logger.error("Only valid document items can be split")
938
750
  return False
939
751
 
940
- if askFirst:
941
- msgYes = SHARED.question(
942
- self.tr("Move '{0}' to Trash?").format(nwItemS.itemName)
943
- )
944
- if not msgYes:
945
- logger.info("Action cancelled by user")
946
- return False
947
-
948
- self.propagateCount(tHandle, 0)
949
-
950
- tIndex = trItemP.indexOfChild(trItemS)
951
- trItemC = trItemP.takeChild(tIndex)
952
- trItemT.addChild(trItemC)
953
-
954
- self._postItemMove(tHandle)
955
- self._alertTreeChange(tHandle, flush=flush)
956
-
957
- logger.debug("Moved item '%s' to Trash", tHandle)
958
-
959
- return True
960
-
961
- def permDeleteItem(self, tHandle: str, askFirst: bool = True, flush: bool = True) -> bool:
962
- """Permanently delete a tree item from the project and the map.
963
- Root items are handled a little different than other items.
964
- """
965
- trItemS = self._getTreeItem(tHandle)
966
- nwItemS = SHARED.project.tree[tHandle]
967
- if trItemS is None or nwItemS is None:
968
- logger.error("Could not find tree item for deletion")
752
+ data, text, status = GuiDocSplit.getData(SHARED.mainGui, tHandle)
753
+ if not status:
754
+ logger.info("Action cancelled by user")
969
755
  return False
970
756
 
971
- if nwItemS.isRootType():
972
- # Only an empty ROOT folder can be deleted
973
- if trItemS.childCount() > 0:
974
- SHARED.error(self.tr("Root folders can only be deleted when they are empty."))
975
- return False
976
-
977
- logger.debug("Permanently deleting root folder '%s'", tHandle)
978
-
979
- tIndex = self.indexOfTopLevelItem(trItemS)
980
- self.takeTopLevelItem(tIndex)
981
- SHARED.project.removeItem(tHandle)
982
- self._treeMap.pop(tHandle, None)
983
- self._alertTreeChange(tHandle, flush=True)
984
-
985
- # These are not emitted by the alert function because the
986
- # item has already been deleted
987
- self.projView.rootFolderChanged.emit(tHandle)
988
- self.projView.treeItemChanged.emit(tHandle)
757
+ headerList = data.get("headerList", [])
758
+ intoFolder = data.get("intoFolder", False)
759
+ docHierarchy = data.get("docHierarchy", False)
989
760
 
761
+ docSplit = DocSplitter(SHARED.project, tHandle)
762
+ if intoFolder:
763
+ docSplit.newParentFolder(tItem.itemParent, tItem.itemName)
990
764
  else:
991
- if askFirst:
992
- msgYes = SHARED.question(
993
- self.tr("Permanently delete '{0}'?").format(nwItemS.itemName)
994
- )
995
- if not msgYes:
996
- logger.info("Action cancelled by user")
997
- return False
998
-
999
- logger.debug("Permanently deleting item '%s'", tHandle)
1000
-
1001
- self.propagateCount(tHandle, 0)
1002
-
1003
- # If a non-root item ends up on root due to a bug, allow it
1004
- # to still be deleted from invisible root (see #2108)
1005
- trItemP = trItemS.parent() or self.invisibleRootItem()
1006
- tIndex = trItemP.indexOfChild(trItemS)
1007
- trItemP.takeChild(tIndex)
765
+ docSplit.setParentItem(tItem.itemParent)
1008
766
 
1009
- for dHandle in reversed(self.getTreeFromHandle(tHandle)):
1010
- SHARED.closeEditor(dHandle)
1011
- SHARED.project.removeItem(dHandle)
1012
- self._treeMap.pop(dHandle, None)
1013
-
1014
- self._alertTreeChange(tHandle, flush=flush)
1015
- self.projView.wordCountsChanged.emit()
767
+ docSplit.splitDocument(headerList, text)
768
+ for writeOk in docSplit.writeDocuments(docHierarchy):
769
+ if not writeOk:
770
+ SHARED.error(
771
+ self.tr("Could not write document content."),
772
+ info=docSplit.getError()
773
+ )
1016
774
 
1017
- # This is not emitted by the alert function because the item
1018
- # has already been deleted
1019
- self.projView.treeItemChanged.emit(tHandle)
775
+ if data.get("moveToTrash", False):
776
+ self.processDeleteRequest([tHandle], False)
1020
777
 
1021
778
  return True
1022
779
 
1023
- def refreshUserLabels(self, kind: str) -> None:
1024
- """Refresh status or importance labels."""
1025
- if kind == "s":
1026
- for nwItem in SHARED.project.tree:
1027
- if nwItem.isNovelLike():
1028
- self.setTreeItemValues(nwItem)
1029
- elif kind == "i":
1030
- for nwItem in SHARED.project.tree:
1031
- if not nwItem.isNovelLike():
1032
- self.setTreeItemValues(nwItem)
1033
- return
1034
-
1035
- def setTreeItemValues(self, nwItem: NWItem | None) -> None:
1036
- """Set the name and flag values for a tree item in the project
1037
- tree. Does not trigger a tree change as the data is already
1038
- coming from project data.
1039
- """
1040
- if isinstance(nwItem, NWItem) and (trItem := self._getTreeItem(nwItem.itemHandle)):
1041
- itemStatus, statusIcon = nwItem.getImportStatus()
1042
- hLevel = nwItem.mainHeading
1043
- itemIcon = SHARED.theme.getItemIcon(
1044
- nwItem.itemType, nwItem.itemClass, nwItem.itemLayout, hLevel
1045
- )
1046
-
1047
- trItem.setIcon(self.C_NAME, itemIcon)
1048
- trItem.setText(self.C_NAME, nwItem.itemName)
1049
- trItem.setIcon(self.C_STATUS, statusIcon)
1050
- trItem.setToolTip(self.C_STATUS, itemStatus)
1051
-
1052
- if nwItem.isFileType():
1053
- iconName = "checked" if nwItem.isActive else "unchecked"
1054
- toolTip = self.trActive if nwItem.isActive else self.trInactive
1055
- trItem.setToolTip(self.C_ACTIVE, toolTip)
780
+ def duplicateFromHandle(self, tHandle: str) -> None:
781
+ """Duplicate the item hierarchy from a given item."""
782
+ itemTree = [tHandle]
783
+ itemTree.extend(SHARED.project.tree.subTree(tHandle))
784
+ if itemTree:
785
+ if len(itemTree) == 1:
786
+ question = self.tr("Do you want to duplicate this document?")
1056
787
  else:
1057
- iconName = "noncheckable"
1058
-
1059
- trItem.setIcon(self.C_ACTIVE, SHARED.theme.getIcon(iconName))
1060
-
1061
- if CONFIG.emphLabels and nwItem.isDocumentLayout():
1062
- trFont = trItem.font(self.C_NAME)
1063
- trFont.setBold(hLevel == "H1" or hLevel == "H2")
1064
- trFont.setUnderline(hLevel == "H1")
1065
- trItem.setFont(self.C_NAME, trFont)
1066
-
1067
- # Emit Refresh Signal
1068
- self.itemRefreshed.emit(nwItem.itemHandle, nwItem, itemIcon)
1069
-
788
+ question = self.tr("Do you want to duplicate this item and all child items?")
789
+ if SHARED.question(question):
790
+ docDup = DocDuplicator(SHARED.project)
791
+ dHandles = docDup.duplicate(itemTree)
792
+ if len(dHandles) != len(itemTree):
793
+ SHARED.warn(self.tr("Could not duplicate all items."))
794
+ self.restoreExpandedState()
1070
795
  return
1071
796
 
1072
- def propagateCount(self, tHandle: str, newCount: int, countChildren: bool = False) -> None:
1073
- """Recursive function setting the word count for a given item,
1074
- and propagating that count upwards in the tree until reaching a
1075
- root item. This function is more efficient than recalculating
1076
- everything each time the word count is updated, but is also
1077
- prone to diverging from the true values if the counts are not
1078
- properly reported to the function.
1079
- """
1080
- tItem = self._getTreeItem(tHandle)
1081
- if tItem is None:
1082
- return
1083
-
1084
- if countChildren:
1085
- for i in range(tItem.childCount()):
1086
- newCount += int(tItem.child(i).data(self.C_DATA, self.D_WORDS))
1087
-
1088
- tItem.setText(self.C_COUNT, f"{newCount:n}")
1089
- tItem.setData(self.C_DATA, self.D_WORDS, int(newCount))
1090
-
1091
- pItem = tItem.parent()
1092
- if pItem is None:
1093
- return
1094
-
1095
- pCount = 0
1096
- pHandle = None
1097
- for i in range(pItem.childCount()):
1098
- pCount += int(pItem.child(i).data(self.C_DATA, self.D_WORDS))
1099
- pHandle = pItem.data(self.C_DATA, self.D_HANDLE)
1100
-
1101
- if pHandle:
1102
- if SHARED.project.tree.checkType(pHandle, nwItemType.FILE):
1103
- # A file has an internal word count we need to account
1104
- # for, but a folder always has 0 words on its own.
1105
- pCount += SHARED.project.index.getCounts(pHandle)[1]
1106
-
1107
- self.propagateCount(pHandle, pCount, countChildren=False)
1108
-
1109
- return
797
+ ##
798
+ # Events and Overloads
799
+ ##
1110
800
 
1111
- def buildTree(self) -> None:
1112
- """Build the entire project tree from scratch. This depends on
1113
- the save project item iterator in the project class which will
1114
- always make sure items with a parent have had their parent item
1115
- sent first.
801
+ def mousePressEvent(self, event: QMouseEvent) -> None:
802
+ """Overload mousePressEvent to clear selection if clicking the
803
+ mouse in a blank area of the tree view, and to load a document
804
+ for viewing if the user middle-clicked.
1116
805
  """
1117
- logger.debug("Building the project tree ...")
1118
- self.clearTree()
1119
- count = 0
1120
- for nwItem in SHARED.project.iterProjectItems():
1121
- count += 1
1122
- self._addTreeItem(nwItem)
1123
- if count > 0:
1124
- logger.info("%d item(s) added to the project tree", count)
806
+ super().mousePressEvent(event)
807
+ if event.button() == QtMouseLeft:
808
+ if not self.indexAt(event.pos()).isValid():
809
+ self._clearSelection()
810
+ elif event.button() == QtMouseMiddle:
811
+ if (node := self._getNode(self.indexAt(event.pos()))) and node.item.isFileType():
812
+ self.projView.openDocumentRequest.emit(
813
+ node.item.itemHandle, nwDocMode.VIEW, "", False
814
+ )
1125
815
  return
1126
816
 
1127
- def getSelectedHandle(self) -> str | None:
1128
- """Get the currently selected handle. If multiple items are
1129
- selected, return the first.
1130
- """
1131
- if items := self.selectedItems():
1132
- return items[0].data(self.C_DATA, self.D_HANDLE)
1133
- return None
1134
-
1135
- def setSelectedHandle(self, tHandle: str | None, doScroll: bool = False) -> bool:
1136
- """Set a specific handle as the selected item."""
1137
- tItem = self._getTreeItem(tHandle)
1138
- if tItem is None:
1139
- return False
1140
-
1141
- if tHandle in self._treeMap:
1142
- self.setCurrentItem(self._treeMap[tHandle])
1143
-
1144
- if (indexes := self.selectedIndexes()) and doScroll:
1145
- self.scrollTo(indexes[0], QAbstractItemView.ScrollHint.PositionAtCenter)
1146
-
1147
- return True
1148
-
1149
- def setExpandedFromHandle(self, tHandle: str | None, isExpanded: bool) -> None:
1150
- """Iterate through items below tHandle and change expanded
1151
- status for all child items. If tHandle is None, it affects the
1152
- entire tree.
1153
- """
1154
- trItem = self._getTreeItem(tHandle) or self.invisibleRootItem()
1155
- self._recursiveSetExpanded(trItem, isExpanded)
817
+ def drawRow(self, painter: QPainter, opt: QStyleOptionViewItem, index: QModelIndex) -> None:
818
+ """Draw a box on the active row."""
819
+ if (node := self._getNode(index)) and node.item.itemHandle == self._actHandle:
820
+ painter.fillRect(opt.rect, self.palette().alternateBase())
821
+ super().drawRow(painter, opt, index)
1156
822
  return
1157
823
 
1158
- def openContextOnSelected(self) -> bool:
1159
- """Open the context menu on the current selected item."""
1160
- if items := self.selectedItems():
1161
- return self._openContextMenu(self.visualItemRect(items[0]).center())
1162
- return False
1163
-
1164
- def changedSince(self, checkTime: float) -> bool:
1165
- """Check if the tree has changed since a given time."""
1166
- return self._timeChanged > checkTime
1167
-
1168
824
  ##
1169
- # Private Slots
825
+ # Public Slots
1170
826
  ##
1171
827
 
1172
828
  @pyqtSlot()
1173
- def _treeSelectionChange(self) -> None:
1174
- """The user changed which item is selected."""
1175
- tHandle = self.getSelectedHandle()
1176
- if tHandle is not None:
1177
- self.projView.selectedItemChanged.emit(tHandle)
829
+ def moveItemUp(self) -> None:
830
+ """Move an item up in the tree."""
831
+ if model := self._getModel():
832
+ model.internalMove(self.currentIndex(), -1)
833
+ return
1178
834
 
1179
- # When selecting multiple items, don't allow including root
1180
- # items in the selection and instead deselect them
1181
- items = self.selectedItems()
1182
- if items and len(items) > 1:
1183
- for item in items:
1184
- if item.parent() is None:
1185
- item.setSelected(False)
835
+ @pyqtSlot()
836
+ def moveItemDown(self) -> None:
837
+ """Move an item down in the tree."""
838
+ if model := self._getModel():
839
+ model.internalMove(self.currentIndex(), 1)
840
+ return
1186
841
 
842
+ @pyqtSlot()
843
+ def goToSiblingUp(self) -> None:
844
+ """Skip to the previous sibling."""
845
+ if (node := self._getNode(self.currentIndex())) and (parent := node.parent()):
846
+ if (move := parent.child(node.row() - 1)) and (model := self._getModel()):
847
+ self.setCurrentIndex(model.indexFromNode(move))
1187
848
  return
1188
849
 
1189
- @pyqtSlot("QTreeWidgetItem*", int)
1190
- def _treeDoubleClick(self, trItem: QTreeWidgetItem, colNo: int) -> None:
1191
- """Capture a double-click event and either request the document
1192
- for editing if it is a file, or expand/close the node it is not.
1193
- """
1194
- tHandle = self.getSelectedHandle()
1195
- if tHandle is None:
1196
- return
850
+ @pyqtSlot()
851
+ def goToSiblingDown(self) -> None:
852
+ """Skip to the next sibling."""
853
+ if (node := self._getNode(self.currentIndex())) and (parent := node.parent()):
854
+ if (move := parent.child(node.row() + 1)) and (model := self._getModel()):
855
+ self.setCurrentIndex(model.indexFromNode(move))
856
+ return
1197
857
 
1198
- tItem = SHARED.project.tree[tHandle]
1199
- if tItem is None:
1200
- return
858
+ @pyqtSlot()
859
+ def goToParent(self) -> None:
860
+ """Move to parent item."""
861
+ if (
862
+ (model := self._getModel())
863
+ and (node := model.node(self.currentIndex()))
864
+ and (parent := node.parent())
865
+ ):
866
+ self.setCurrentIndex(model.indexFromNode(parent))
867
+ return
1201
868
 
1202
- if tItem.isFileType():
1203
- self.projView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, "", True)
1204
- else:
1205
- trItem.setExpanded(not trItem.isExpanded())
869
+ @pyqtSlot()
870
+ def goToFirstChild(self) -> None:
871
+ """Move to first child item."""
872
+ if (
873
+ (model := self._getModel())
874
+ and (node := model.node(self.currentIndex()))
875
+ and (child := node.child(0))
876
+ ):
877
+ self.setCurrentIndex(model.indexFromNode(child))
878
+ return
1206
879
 
880
+ @pyqtSlot(QModelIndex)
881
+ def expandFromIndex(self, index: QModelIndex) -> None:
882
+ """Expand all nodes from index."""
883
+ self.expandRecursively(index)
1207
884
  return
1208
885
 
1209
- @pyqtSlot("QPoint")
1210
- def _openContextMenu(self, clickPos: QPoint) -> bool:
1211
- """The user right clicked an element in the project tree, so we
1212
- open a context menu in-place.
1213
- """
1214
- tItem = None
1215
- tHandle = None
1216
- hasChild = False
1217
- sItem = self.itemAt(clickPos)
1218
- sItems = self.selectedItems()
1219
- if isinstance(sItem, QTreeWidgetItem):
1220
- tHandle = sItem.data(self.C_DATA, self.D_HANDLE)
1221
- tItem = SHARED.project.tree[tHandle]
1222
- hasChild = sItem.childCount() > 0
1223
-
1224
- if tItem is None or tHandle is None:
1225
- logger.debug("No item found")
1226
- return False
886
+ @pyqtSlot(QModelIndex)
887
+ def collapseFromIndex(self, index: QModelIndex) -> None:
888
+ """Collapse all nodes from index."""
889
+ if (model := self._getModel()) and (node := model.node(index)):
890
+ for child in node.allChildren():
891
+ self.setExpanded(model.indexFromNode(child), False)
892
+ return
1227
893
 
1228
- ctxMenu = _TreeContextMenu(self, tItem)
1229
- trashHandle = SHARED.project.tree.trashRoot
1230
- if trashHandle and tHandle == trashHandle:
1231
- ctxMenu.buildTrashMenu()
1232
- elif len(sItems) > 1:
1233
- handles = [str(x.data(self.C_DATA, self.D_HANDLE)) for x in sItems]
1234
- ctxMenu.buildMultiSelectMenu(handles)
894
+ @pyqtSlot()
895
+ def processDeleteRequest(
896
+ self, handles: list[str] | None = None, askFirst: bool = True
897
+ ) -> None:
898
+ """Move selected items to Trash."""
899
+ if handles and (model := self._getModel()):
900
+ indices = [model.indexFromHandle(handle) for handle in handles]
1235
901
  else:
1236
- ctxMenu.buildSingleSelectMenu(hasChild)
902
+ indices = self._selectedRows()
1237
903
 
1238
- ctxMenu.exec(self.viewport().mapToGlobal(clickPos))
1239
- ctxMenu.deleteLater()
904
+ if indices and (model := self._getModel()):
905
+ if len(indices) == 1 and (node := model.node(indices[0])) and node.item.isRootType():
906
+ if node.childCount() == 0:
907
+ SHARED.project.removeItem(node.item.itemHandle)
908
+ else:
909
+ SHARED.error(self.tr("Root folders can only be deleted when they are empty."))
910
+ return
1240
911
 
1241
- return True
912
+ if model.trashSelection(indices):
913
+ if not SHARED.question(self.tr("Permanently delete selected item(s)?")):
914
+ logger.info("Action cancelled by user")
915
+ return
916
+ for index in indices:
917
+ if node := model.node(index):
918
+ for child in reversed(node.allChildren()):
919
+ SHARED.project.removeItem(child.item.itemHandle)
920
+ SHARED.project.removeItem(node.item.itemHandle)
921
+
922
+ elif trashNode := SHARED.project.tree.trash:
923
+ if askFirst and not SHARED.question(self.tr("Move selected item(s) to Trash?")):
924
+ logger.info("Action cancelled by user")
925
+ return
926
+ model.multiMove(indices, model.indexFromNode(trashNode))
1242
927
 
1243
- @pyqtSlot()
1244
- def _doAutoScroll(self) -> None:
1245
- """Scroll one item up or down based on direction value."""
1246
- if self._scrollDirection == -1:
1247
- self.scrollToItem(self.itemAbove(self.itemAt(1, 1)))
1248
- elif self._scrollDirection == 1:
1249
- self.scrollToItem(self.itemBelow(self.itemAt(1, self.height() - 1)))
1250
- self._scrollDirection = 0
1251
- self._scrollTimer.stop()
1252
928
  return
1253
929
 
1254
- ##
1255
- # Events
1256
- ##
1257
-
1258
- def mousePressEvent(self, event: QMouseEvent) -> None:
1259
- """Overload mousePressEvent to clear selection if clicking the
1260
- mouse in a blank area of the tree view, and to load a document
1261
- for viewing if the user middle-clicked.
930
+ @pyqtSlot()
931
+ def emptyTrash(self) -> None:
932
+ """Permanently delete all documents in the Trash folder. This
933
+ function only asks for confirmation once, and calls the regular
934
+ deleteItem function for each document in the Trash folder.
1262
935
  """
1263
- super().mousePressEvent(event)
1264
- if event.button() == QtMouseLeft:
1265
- selItem = self.indexAt(event.pos())
1266
- if not selItem.isValid():
1267
- self.clearSelection()
1268
- elif event.button() == QtMouseMiddle:
1269
- selItem = self.itemAt(event.pos())
1270
- if selItem:
1271
- tHandle = selItem.data(self.C_DATA, self.D_HANDLE)
1272
- if (tItem := SHARED.project.tree[tHandle]) and tItem.isFileType():
1273
- self.projView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, "", False)
1274
- return
1275
-
1276
- def startDrag(self, dropAction: Qt.DropAction) -> None:
1277
- """Capture the drag and drop handling to pop alerts."""
1278
- super().startDrag(dropAction)
1279
- if self._popAlert:
1280
- SHARED.error(self._popAlert)
1281
- self._popAlert = None
936
+ if trash := SHARED.project.tree.trash:
937
+ if not (nodes := trash.allChildren()):
938
+ SHARED.info(self.tr("The Trash folder is already empty."))
939
+ return
940
+ if not SHARED.question(
941
+ self.tr("Permanently delete {0} file(s) from Trash?").format(len(nodes))
942
+ ):
943
+ logger.info("Action cancelled by user")
944
+ return
945
+ for node in reversed(nodes):
946
+ SHARED.project.removeItem(node.item.itemHandle)
1282
947
  return
1283
948
 
1284
- def dragEnterEvent(self, event: QDragEnterEvent) -> None:
1285
- """Check that we're only dragging items that are siblings, and
1286
- not a root level item.
1287
- """
1288
- items = self.selectedItems()
1289
- if items and (parent := items[0].parent()) and all(x.parent() is parent for x in items):
1290
- super().dragEnterEvent(event)
1291
- else:
1292
- logger.warning("Drag action is not allowed and has been cancelled")
1293
- self._popAlert = self.tr(
1294
- "Drag and drop is only allowed for single items, non-root "
1295
- "items, or multiple items with the same parent."
1296
- )
1297
- event.mimeData().clear()
1298
- event.ignore()
1299
- return
1300
-
1301
- def dragMoveEvent(self, event: QDragMoveEvent) -> None:
1302
- """Capture the drag move event to enable edge auto scroll."""
1303
- y = event.pos().y()
1304
- if y < self._scrollMargin:
1305
- if not self._scrollTimer.isActive():
1306
- self._scrollDirection = -1
1307
- self._scrollTimer.start()
1308
- elif y > self.height() - self._scrollMargin:
1309
- if not self._scrollTimer.isActive():
1310
- self._scrollDirection = 1
1311
- self._scrollTimer.start()
1312
- super().dragMoveEvent(event)
1313
- return
1314
-
1315
- def dropEvent(self, event: QDropEvent) -> None:
1316
- """Overload the drop item event to ensure the drag and drop
1317
- action is allowed, and update relevant data.
949
+ @pyqtSlot()
950
+ @pyqtSlot("QPoint")
951
+ def openContextMenu(self, point: QPoint | None = None) -> None:
952
+ """The user right clicked an element in the project tree, so we
953
+ open a context menu in-place.
1318
954
  """
1319
- tItem = self.itemAt(event.pos())
1320
- onItem = self.dropIndicatorPosition() == QAbstractItemView.DropIndicatorPosition.OnItem
1321
- onView = self.dropIndicatorPosition() == QAbstractItemView.DropIndicatorPosition.OnViewport
1322
- # Make sure nothing can be dropped on invisible root (see #1569)
1323
- # Make sure nothing can be dropped on the viewport (see #2108)
1324
- if tItem is None or (tItem.parent() is None and not onItem) or onView:
1325
- logger.error("Invalid drop location")
1326
- event.ignore()
1327
- return
1328
-
1329
- mItems: dict[str, tuple[QTreeWidgetItem, bool]] = {}
1330
- sItems = self.selectedItems()
1331
- if sItems and (parent := sItems[0].parent()) and all(x.parent() is parent for x in sItems):
1332
- for sItem in sItems:
1333
- mHandle = str(sItem.data(self.C_DATA, self.D_HANDLE))
1334
- mItems[mHandle] = (sItem, sItem.isExpanded())
1335
- self.propagateCount(mHandle, 0)
1336
-
1337
- super().dropEvent(event)
1338
-
1339
- for mHandle, (sItem, isExpanded) in mItems.items():
1340
- self._postItemMove(mHandle)
1341
- sItem.setExpanded(isExpanded)
1342
- self._alertTreeChange(mHandle, flush=False)
955
+ if model := self._getModel():
956
+ if point is None:
957
+ point = self.visualRect(self.currentIndex()).center()
958
+
959
+ if (
960
+ point is not None
961
+ and (node := self._getNode(self.currentIndex()))
962
+ and (indices := self._selectedRows())
963
+ ):
964
+ ctxMenu = _TreeContextMenu(self, model, node, indices)
965
+ if node is SHARED.project.tree.trash:
966
+ ctxMenu.buildTrashMenu()
967
+ elif len(indices) > 1:
968
+ ctxMenu.buildMultiSelectMenu()
969
+ else:
970
+ ctxMenu.buildSingleSelectMenu()
1343
971
 
1344
- self.saveTreeOrder()
972
+ ctxMenu.exec(self.viewport().mapToGlobal(point))
973
+ ctxMenu.deleteLater()
1345
974
 
1346
975
  return
1347
976
 
1348
977
  ##
1349
- # Internal Functions
978
+ # Private Slots
1350
979
  ##
1351
980
 
1352
- def _postItemMove(self, tHandle: str) -> None:
1353
- """Run various maintenance tasks for a moved item."""
1354
- trItemS = self._getTreeItem(tHandle)
1355
- nwItemS = SHARED.project.tree[tHandle]
1356
- trItemP = trItemS.parent() if trItemS else None
1357
- if trItemP is None or nwItemS is None:
1358
- logger.error("Failed to find new parent item of '%s'", tHandle)
1359
- return
1360
-
1361
- # Update item parent handle in the project
1362
- pHandle = trItemP.data(self.C_DATA, self.D_HANDLE)
1363
- nwItemS.setParent(pHandle)
1364
- trItemP.setExpanded(True)
1365
- logger.debug("The parent of item '%s' has been changed to '%s'", tHandle, pHandle)
1366
-
1367
- mHandles = self.getTreeFromHandle(tHandle)
1368
- logger.debug("A total of %d item(s) were moved", len(mHandles))
1369
- for mHandle in mHandles:
1370
- logger.debug("Updating item '%s'", mHandle)
1371
- SHARED.project.tree.updateItemData(mHandle)
1372
- if nwItemS.isInactiveClass():
1373
- SHARED.project.index.deleteHandle(mHandle)
1374
- else:
1375
- SHARED.project.index.reIndexHandle(mHandle)
1376
- if mItem := SHARED.project.tree[mHandle]:
1377
- self.setTreeItemValues(mItem)
1378
-
1379
- # Update word count
1380
- self.propagateCount(tHandle, nwItemS.wordCount, countChildren=True)
1381
-
981
+ @pyqtSlot(QModelIndex)
982
+ def _onSingleClick(self, index: QModelIndex) -> None:
983
+ """The user changed which item is selected."""
984
+ if node := self._getNode(index):
985
+ self.projView.selectedItemChanged.emit(node.item.itemHandle)
1382
986
  return
1383
987
 
1384
- def _getItemWordCount(self, tHandle: str) -> int:
1385
- """Return the word count of a given item handle."""
1386
- tItem = self._getTreeItem(tHandle)
1387
- return int(tItem.data(self.C_DATA, self.D_WORDS)) if tItem else 0
1388
-
1389
- def _getTreeItem(self, tHandle: str | None) -> QTreeWidgetItem | None:
1390
- """Return the QTreeWidgetItem of a given item handle."""
1391
- return self._treeMap.get(tHandle, None) if tHandle else None
1392
-
1393
- def _recursiveSetExpanded(self, trItem: QTreeWidgetItem, isExpanded: bool) -> None:
1394
- """Recursive function to set expanded status starting from (and
1395
- not including) a given item.
988
+ @pyqtSlot(QModelIndex)
989
+ def _onDoubleClick(self, index: QModelIndex) -> None:
990
+ """Capture a double-click event and either request the document
991
+ for editing if it is a file, or expand/close the node if not.
1396
992
  """
1397
- if isinstance(trItem, QTreeWidgetItem):
1398
- for i in range(trItem.childCount()):
1399
- chItem = trItem.child(i)
1400
- chItem.setExpanded(isExpanded)
1401
- self._recursiveSetExpanded(chItem, isExpanded)
1402
- return
1403
-
1404
- def _mergeDocuments(self, tHandle: str, newFile: bool) -> bool:
1405
- """Merge an item's child documents into a single document."""
1406
- logger.info("Request to merge items under handle '%s'", tHandle)
1407
- itemList = self.getTreeFromHandle(tHandle)
1408
-
1409
- tItem = SHARED.project.tree[tHandle]
1410
- if tItem is None:
1411
- return False
1412
-
1413
- if tItem.isRootType():
1414
- logger.error("Cannot merge root item")
1415
- return False
1416
-
1417
- if not newFile:
1418
- itemList.remove(tHandle)
1419
-
1420
- data, status = GuiDocMerge.getData(SHARED.mainGui, tHandle, itemList)
1421
- if status:
1422
- items = data.get("finalItems", [])
1423
- if not items:
1424
- SHARED.info(self.tr("No documents selected for merging."))
1425
- return False
1426
-
1427
- # Save the open document first, in case it's part of merge
1428
- SHARED.saveEditor()
1429
-
1430
- # Create merge object, and append docs
1431
- docMerger = DocMerger(SHARED.project)
1432
- mLabel = self.tr("Merged")
1433
-
1434
- if newFile:
1435
- docLabel = f"[{mLabel}] {tItem.itemName}"
1436
- mHandle = docMerger.newTargetDoc(tHandle, docLabel)
1437
- elif tItem.isFileType():
1438
- docMerger.setTargetDoc(tHandle)
1439
- mHandle = tHandle
1440
- else:
1441
- return False
1442
-
1443
- for sHandle in items:
1444
- docMerger.appendText(sHandle, True, mLabel)
1445
-
1446
- if not docMerger.writeTargetDoc():
1447
- SHARED.error(
1448
- self.tr("Could not write document content."),
1449
- info=docMerger.getError()
993
+ if node := self._getNode(index):
994
+ if node.item.isFileType():
995
+ self.projView.openDocumentRequest.emit(
996
+ node.item.itemHandle, nwDocMode.EDIT, "", True
1450
997
  )
1451
- return False
1452
-
1453
- SHARED.project.index.reIndexHandle(mHandle)
1454
- if newFile:
1455
- self.revealNewTreeItem(mHandle, nHandle=tHandle, wordCount=True)
1456
-
1457
- self.projView.openDocumentRequest.emit(mHandle, nwDocMode.EDIT, "", False)
1458
- self.projView.setSelectedHandle(mHandle, doScroll=True)
1459
-
1460
- if data.get("moveToTrash", False):
1461
- for sHandle in reversed(data.get("finalItems", [])):
1462
- trItem = self._getTreeItem(sHandle)
1463
- if isinstance(trItem, QTreeWidgetItem) and trItem.childCount() == 0:
1464
- self.moveItemToTrash(sHandle, askFirst=False, flush=False)
1465
-
1466
- self._alertTreeChange(mHandle, flush=True)
1467
- self.projView.wordCountsChanged.emit()
1468
-
1469
- else:
1470
- logger.info("Action cancelled by user")
1471
- return False
1472
-
1473
- return True
1474
-
1475
- def _splitDocument(self, tHandle: str) -> bool:
1476
- """Split a document into multiple documents."""
1477
- logger.info("Request to split items with handle '%s'", tHandle)
1478
-
1479
- tItem = SHARED.project.tree[tHandle]
1480
- if tItem is None:
1481
- return False
1482
-
1483
- if not tItem.isFileType() or tItem.itemParent is None:
1484
- logger.error("Only valid document items can be split")
1485
- return False
1486
-
1487
- data, text, status = GuiDocSplit.getData(SHARED.mainGui, tHandle)
1488
- if status:
1489
- headerList = data.get("headerList", [])
1490
- intoFolder = data.get("intoFolder", False)
1491
- docHierarchy = data.get("docHierarchy", False)
1492
-
1493
- docSplit = DocSplitter(SHARED.project, tHandle)
1494
- if intoFolder:
1495
- fHandle = docSplit.newParentFolder(tItem.itemParent, tItem.itemName)
1496
- self.revealNewTreeItem(fHandle, nHandle=tHandle)
1497
- self._alertTreeChange(fHandle, flush=False)
1498
998
  else:
1499
- docSplit.setParentItem(tItem.itemParent)
1500
-
1501
- docSplit.splitDocument(headerList, text)
1502
- for writeOk, dHandle, nHandle in docSplit.writeDocuments(docHierarchy):
1503
- SHARED.project.index.reIndexHandle(dHandle)
1504
- self.revealNewTreeItem(dHandle, nHandle=nHandle, wordCount=True)
1505
- self._alertTreeChange(dHandle, flush=False)
1506
- if not writeOk:
1507
- SHARED.error(
1508
- self.tr("Could not write document content."),
1509
- info=docSplit.getError()
1510
- )
1511
-
1512
- if data.get("moveToTrash", False):
1513
- self.moveItemToTrash(tHandle, askFirst=False, flush=True)
1514
-
1515
- self.saveTreeOrder()
1516
-
1517
- else:
1518
- logger.info("Action cancelled by user")
1519
- return False
1520
-
1521
- return True
1522
-
1523
- def _duplicateFromHandle(self, tHandle: str) -> bool:
1524
- """Duplicate the item hierarchy from a given item."""
1525
- itemTree = self.getTreeFromHandle(tHandle)
1526
- nItems = len(itemTree)
1527
- if nItems == 0:
1528
- return False
1529
- elif nItems == 1:
1530
- question = self.tr("Do you want to duplicate this document?")
1531
- else:
1532
- question = self.tr("Do you want to duplicate this item and all child items?")
1533
-
1534
- if not SHARED.question(question):
1535
- return False
1536
-
1537
- docDup = DocDuplicator(SHARED.project)
1538
- dupCount = 0
1539
- for dHandle, nHandle in docDup.duplicate(itemTree):
1540
- SHARED.project.index.reIndexHandle(dHandle)
1541
- self.revealNewTreeItem(dHandle, nHandle=nHandle, wordCount=True)
1542
- self._alertTreeChange(dHandle, flush=False)
1543
- dupCount += 1
1544
-
1545
- if dupCount != nItems:
1546
- SHARED.warn(self.tr("Could not duplicate all items."))
1547
-
1548
- self.saveTreeOrder()
1549
-
1550
- return True
1551
-
1552
- def _scanChildren(self, itemList: list, tItem: QTreeWidgetItem, tIndex: int) -> list[str]:
1553
- """This is a recursive function returning all items in a tree
1554
- starting at a given QTreeWidgetItem.
1555
- """
1556
- tHandle = tItem.data(self.C_DATA, self.D_HANDLE)
1557
- cCount = tItem.childCount()
1558
-
1559
- # Update tree-related meta data
1560
- nwItem = SHARED.project.tree[tHandle]
1561
- if nwItem is not None:
1562
- nwItem.setExpanded(tItem.isExpanded() and cCount > 0)
1563
- nwItem.setOrder(tIndex)
1564
-
1565
- itemList.append(tHandle)
1566
- for i in range(cCount):
1567
- self._scanChildren(itemList, tItem.child(i), i)
1568
-
1569
- return itemList
1570
-
1571
- def _addTreeItem(self, nwItem: NWItem | None,
1572
- nHandle: str | None = None) -> QTreeWidgetItem | None:
1573
- """Create a QTreeWidgetItem from an NWItem and add it to the
1574
- project tree. Returns the widget if the item is valid, otherwise
1575
- a None is returned.
1576
- """
1577
- if not nwItem:
1578
- logger.error("Invalid item cannot be added to project tree")
1579
- return None
1580
-
1581
- tHandle = nwItem.itemHandle
1582
- pHandle = nwItem.itemParent
1583
- newItem = QTreeWidgetItem()
1584
-
1585
- newItem.setText(self.C_NAME, "")
1586
- newItem.setText(self.C_COUNT, "0")
1587
- newItem.setText(self.C_ACTIVE, "")
1588
- newItem.setText(self.C_STATUS, "")
1589
-
1590
- newItem.setTextAlignment(self.C_NAME, QtAlignLeft)
1591
- newItem.setTextAlignment(self.C_COUNT, QtAlignRight)
1592
- newItem.setTextAlignment(self.C_ACTIVE, QtAlignLeft)
1593
- newItem.setTextAlignment(self.C_STATUS, QtAlignLeft)
1594
-
1595
- newItem.setData(self.C_DATA, self.D_HANDLE, tHandle)
1596
- newItem.setData(self.C_DATA, self.D_WORDS, 0)
1597
-
1598
- if pHandle is None and nwItem.isRootType():
1599
- pItem = self.invisibleRootItem()
1600
- elif pHandle and pHandle in self._treeMap:
1601
- pItem = self._treeMap[pHandle]
1602
- else:
1603
- SHARED.error(self.tr(
1604
- "There is nowhere to add item with name '{0}'."
1605
- ).format(nwItem.itemName))
1606
- return None
1607
-
1608
- byIndex = -1
1609
- if nHandle is not None and nHandle in self._treeMap:
1610
- byIndex = pItem.indexOfChild(self._treeMap[nHandle])
1611
- if byIndex >= 0:
1612
- pItem.insertChild(byIndex + 1, newItem)
1613
- else:
1614
- pItem.addChild(newItem)
999
+ self.setExpanded(index, not self.isExpanded(index))
1000
+ return
1615
1001
 
1616
- self._treeMap[tHandle] = newItem
1617
- self.propagateCount(tHandle, nwItem.wordCount, countChildren=True)
1618
- self.setTreeItemValues(nwItem)
1619
- newItem.setExpanded(nwItem.isExpanded)
1002
+ @pyqtSlot(QModelIndex)
1003
+ def _onNodeCollapsed(self, index: QModelIndex) -> None:
1004
+ """Capture a node collapse, and pass it to the model."""
1005
+ if node := self._getNode(index):
1006
+ node.setExpanded(False)
1007
+ return
1620
1008
 
1621
- return newItem
1009
+ @pyqtSlot(QModelIndex)
1010
+ def _onNodeExpanded(self, index: QModelIndex) -> None:
1011
+ """Capture a node expand, and pass it to the model."""
1012
+ if node := self._getNode(index):
1013
+ node.setExpanded(True)
1014
+ return
1622
1015
 
1623
- def _addTrashRoot(self) -> QTreeWidgetItem | None:
1624
- """Adds the trash root folder if it doesn't already exist in the
1625
- project tree.
1626
- """
1627
- trashHandle = SHARED.project.trashFolder()
1628
- if trashHandle is None:
1629
- return None
1630
-
1631
- trItem = self._getTreeItem(trashHandle)
1632
- if trItem is None:
1633
- trItem = self._addTreeItem(SHARED.project.tree[trashHandle])
1634
- if trItem is not None:
1635
- trItem.setExpanded(True)
1636
- self._alertTreeChange(trashHandle, flush=True)
1637
-
1638
- return trItem
1639
-
1640
- def _alertTreeChange(self, tHandle: str | None, flush: bool = False) -> None:
1641
- """Update information on tree change state, and emit necessary
1642
- signals. A flush is only needed if an item is moved, created or
1643
- deleted.
1644
- """
1645
- self._timeChanged = time()
1646
- SHARED.project.setProjectChanged(True)
1647
- if flush:
1648
- self.saveTreeOrder()
1016
+ ##
1017
+ # Internal Functions
1018
+ ##
1649
1019
 
1650
- if tHandle is None or tHandle not in SHARED.project.tree:
1651
- return
1020
+ def _clearSelection(self) -> None:
1021
+ """Clear the currently selected items."""
1022
+ self.clearSelection()
1023
+ self.selectionModel().clearCurrentIndex()
1024
+ return
1652
1025
 
1653
- tItem = SHARED.project.tree[tHandle]
1654
- if tItem and tItem.isRootType():
1655
- self.projView.rootFolderChanged.emit(tHandle)
1026
+ def _selectedRows(self) -> list[QModelIndex]:
1027
+ """Return all column 0 indexes."""
1028
+ return [i for i in self.selectedIndexes() if i.column() == 0]
1656
1029
 
1657
- self.projView.treeItemChanged.emit(tHandle)
1030
+ def _getModel(self) -> ProjectModel | None:
1031
+ """Return a project node corresponding to a model index."""
1032
+ if isinstance(model := self.model(), ProjectModel):
1033
+ return model
1034
+ return None
1658
1035
 
1659
- return
1036
+ def _getNode(self, index: QModelIndex) -> ProjectNode | None:
1037
+ """Return a project node corresponding to a model index."""
1038
+ if isinstance(model := self.model(), ProjectModel) and (node := model.node(index)):
1039
+ return node
1040
+ return None
1660
1041
 
1661
1042
 
1662
1043
  class _UpdatableMenu(QMenu):
@@ -1724,18 +1105,22 @@ class _UpdatableMenu(QMenu):
1724
1105
 
1725
1106
  class _TreeContextMenu(QMenu):
1726
1107
 
1727
- def __init__(self, projTree: GuiProjectTree, nwItem: NWItem) -> None:
1728
- super().__init__(parent=projTree)
1729
-
1730
- self.projTree = projTree
1731
- self.projView = projTree.projView
1732
-
1733
- self._item = nwItem
1734
- self._handle = nwItem.itemHandle
1735
- self._items: list[NWItem] = []
1108
+ __slots__ = ("_tree", "_view", "_node", "_item", "_model", "_handle", "_indices", "_children")
1736
1109
 
1110
+ def __init__(
1111
+ self, projTree: GuiProjectTree, model: ProjectModel,
1112
+ node: ProjectNode, indices: list[QModelIndex]
1113
+ ) -> None:
1114
+ super().__init__(parent=projTree)
1115
+ self._tree = projTree
1116
+ self._view = projTree.projView
1117
+ self._node = node
1118
+ self._item = node.item
1119
+ self._model = model
1120
+ self._handle = node.item.itemHandle
1121
+ self._indices = indices
1122
+ self._children = node.childCount() > 0
1737
1123
  logger.debug("Ready: _TreeContextMenu")
1738
-
1739
1124
  return
1740
1125
 
1741
1126
  def __del__(self) -> None: # pragma: no cover
@@ -1749,14 +1134,15 @@ class _TreeContextMenu(QMenu):
1749
1134
  def buildTrashMenu(self) -> None:
1750
1135
  """Build the special menu for the Trash folder."""
1751
1136
  action = self.addAction(self.tr("Empty Trash"))
1752
- action.triggered.connect(self.projTree.emptyTrash)
1137
+ action.triggered.connect(self._tree.emptyTrash)
1138
+ if self._children:
1139
+ self._expandCollapse()
1753
1140
  return
1754
1141
 
1755
- def buildSingleSelectMenu(self, hasChild: bool) -> None:
1142
+ def buildSingleSelectMenu(self) -> None:
1756
1143
  """Build the single-select menu."""
1757
1144
  isFile = self._item.isFileType()
1758
1145
  isFolder = self._item.isFolderType()
1759
- isRoot = self._item.isRootType()
1760
1146
 
1761
1147
  # Document Actions
1762
1148
  if isFile:
@@ -1769,33 +1155,32 @@ class _TreeContextMenu(QMenu):
1769
1155
 
1770
1156
  # Edit Item Settings
1771
1157
  action = self.addAction(self.tr("Rename"))
1772
- action.triggered.connect(lambda: self.projTree.renameTreeItem(self._handle))
1158
+ action.triggered.connect(qtLambda(self._view.renameTreeItem, self._handle))
1773
1159
  if isFile:
1774
1160
  self._itemHeader()
1775
- self._itemActive(False)
1161
+ self._itemActive()
1776
1162
  self._itemStatusImport(False)
1777
1163
 
1778
1164
  # Transform Item
1779
1165
  if isFile or isFolder:
1780
- self._itemTransform(isFile, isFolder, hasChild)
1166
+ self._itemTransform(isFile, isFolder)
1781
1167
  self.addSeparator()
1782
1168
 
1783
1169
  # Process Item
1784
- self._itemProcess(isFile, isFolder, isRoot, hasChild)
1170
+ if self._children:
1171
+ self._expandCollapse()
1172
+ action = self.addAction(self.tr("Duplicate"))
1173
+ action.triggered.connect(qtLambda(self._tree.duplicateFromHandle, self._handle))
1174
+ self._deleteOrTrash()
1785
1175
 
1786
1176
  return
1787
1177
 
1788
- def buildMultiSelectMenu(self, handles: list[str]) -> None:
1178
+ def buildMultiSelectMenu(self) -> None:
1789
1179
  """Build the multi-select menu."""
1790
- self._items = []
1791
- for tHandle in handles:
1792
- if (tItem := SHARED.project.tree[tHandle]):
1793
- self._items.append(tItem)
1794
-
1795
- self._itemActive(True)
1180
+ self._itemActive()
1796
1181
  self._itemStatusImport(True)
1797
1182
  self.addSeparator()
1798
- self._multiMoveToTrash()
1183
+ self._deleteOrTrash()
1799
1184
  return
1800
1185
 
1801
1186
  ##
@@ -1805,23 +1190,25 @@ class _TreeContextMenu(QMenu):
1805
1190
  def _docActions(self) -> None:
1806
1191
  """Add document actions."""
1807
1192
  action = self.addAction(self.tr("Open Document"))
1808
- action.triggered.connect(
1809
- lambda: self.projView.openDocumentRequest.emit(self._handle, nwDocMode.EDIT, "", True)
1810
- )
1193
+ action.triggered.connect(qtLambda(
1194
+ self._view.openDocumentRequest.emit,
1195
+ self._handle, nwDocMode.EDIT, "", True
1196
+ ))
1811
1197
  action = self.addAction(self.tr("View Document"))
1812
- action.triggered.connect(
1813
- lambda: self.projView.openDocumentRequest.emit(self._handle, nwDocMode.VIEW, "", False)
1814
- )
1198
+ action.triggered.connect(qtLambda(
1199
+ self._view.openDocumentRequest.emit,
1200
+ self._handle, nwDocMode.VIEW, "", False
1201
+ ))
1815
1202
  return
1816
1203
 
1817
1204
  def _itemCreation(self) -> None:
1818
1205
  """Add create item actions."""
1819
1206
  menu = self.addMenu(self.tr("Create New ..."))
1820
- menu.addAction(self.projView.projBar.aAddEmpty)
1821
- menu.addAction(self.projView.projBar.aAddChap)
1822
- menu.addAction(self.projView.projBar.aAddScene)
1823
- menu.addAction(self.projView.projBar.aAddNote)
1824
- menu.addAction(self.projView.projBar.aAddFolder)
1207
+ menu.addAction(self._view.projBar.aAddEmpty)
1208
+ menu.addAction(self._view.projBar.aAddChap)
1209
+ menu.addAction(self._view.projBar.aAddScene)
1210
+ menu.addAction(self._view.projBar.aAddNote)
1211
+ menu.addAction(self._view.projBar.aAddFolder)
1825
1212
  return
1826
1213
 
1827
1214
  def _itemHeader(self) -> None:
@@ -1830,18 +1217,18 @@ class _TreeContextMenu(QMenu):
1830
1217
  if hItem := SHARED.project.index.getItemHeading(self._handle, "T0001"):
1831
1218
  action = self.addAction(self.tr("Rename to Heading"))
1832
1219
  action.triggered.connect(
1833
- lambda: self.projTree.renameTreeItem(self._handle, hItem.title)
1220
+ qtLambda(self._view.renameTreeItem, self._handle, hItem.title)
1834
1221
  )
1835
1222
  return
1836
1223
 
1837
- def _itemActive(self, multi: bool) -> None:
1224
+ def _itemActive(self) -> None:
1838
1225
  """Add Active/Inactive actions."""
1839
- if multi:
1226
+ if len(self._indices) > 1:
1840
1227
  mSub = self.addMenu(self.tr("Set Active to ..."))
1841
- aOne = mSub.addAction(SHARED.theme.getIcon("checked"), self.projTree.trActive)
1842
- aOne.triggered.connect(lambda: self._iterItemActive(True))
1843
- aTwo = mSub.addAction(SHARED.theme.getIcon("unchecked"), self.projTree.trInactive)
1844
- aTwo.triggered.connect(lambda: self._iterItemActive(False))
1228
+ aOne = mSub.addAction(SHARED.theme.getIcon("checked"), self._tree.trActive)
1229
+ aOne.triggered.connect(qtLambda(self._iterItemActive, True))
1230
+ aTwo = mSub.addAction(SHARED.theme.getIcon("unchecked"), self._tree.trInactive)
1231
+ aTwo.triggered.connect(qtLambda(self._iterItemActive, False))
1845
1232
  else:
1846
1233
  action = self.addAction(self.tr("Toggle Active"))
1847
1234
  action.triggered.connect(self._toggleItemActive)
@@ -1852,46 +1239,45 @@ class _TreeContextMenu(QMenu):
1852
1239
  if self._item.isNovelLike():
1853
1240
  menu = self.addMenu(self.tr("Set Status to ..."))
1854
1241
  current = self._item.itemStatus
1855
- for n, (key, entry) in enumerate(SHARED.project.data.itemStatus.iterItems()):
1242
+ for key, entry in SHARED.project.data.itemStatus.iterItems():
1856
1243
  name = entry.name
1857
1244
  if not multi and current == key:
1858
1245
  name += f" ({nwUnicode.U_CHECK})"
1859
1246
  action = menu.addAction(entry.icon, name)
1860
1247
  if multi:
1861
- action.triggered.connect(lambda n, key=key: self._iterSetItemStatus(key))
1248
+ action.triggered.connect(qtLambda(self._iterSetItemStatus, key))
1862
1249
  else:
1863
- action.triggered.connect(lambda n, key=key: self._changeItemStatus(key))
1250
+ action.triggered.connect(qtLambda(self._changeItemStatus, key))
1864
1251
  menu.addSeparator()
1865
1252
  action = menu.addAction(self.tr("Manage Labels ..."))
1866
- action.triggered.connect(
1867
- lambda: self.projView.projectSettingsRequest.emit(GuiProjectSettings.PAGE_STATUS)
1868
- )
1253
+ action.triggered.connect(qtLambda(
1254
+ self._view.projectSettingsRequest.emit,
1255
+ GuiProjectSettings.PAGE_STATUS
1256
+ ))
1869
1257
  else:
1870
1258
  menu = self.addMenu(self.tr("Set Importance to ..."))
1871
1259
  current = self._item.itemImport
1872
- for n, (key, entry) in enumerate(SHARED.project.data.itemImport.iterItems()):
1260
+ for key, entry in SHARED.project.data.itemImport.iterItems():
1873
1261
  name = entry.name
1874
1262
  if not multi and current == key:
1875
1263
  name += f" ({nwUnicode.U_CHECK})"
1876
1264
  action = menu.addAction(entry.icon, name)
1877
1265
  if multi:
1878
- action.triggered.connect(lambda n, key=key: self._iterSetItemImport(key))
1266
+ action.triggered.connect(qtLambda(self._iterSetItemImport, key))
1879
1267
  else:
1880
- action.triggered.connect(lambda n, key=key: self._changeItemImport(key))
1268
+ action.triggered.connect(qtLambda(self._changeItemImport, key))
1881
1269
  menu.addSeparator()
1882
1270
  action = menu.addAction(self.tr("Manage Labels ..."))
1883
- action.triggered.connect(
1884
- lambda: self.projView.projectSettingsRequest.emit(GuiProjectSettings.PAGE_IMPORT)
1885
- )
1271
+ action.triggered.connect(qtLambda(
1272
+ self._view.projectSettingsRequest.emit,
1273
+ GuiProjectSettings.PAGE_IMPORT
1274
+ ))
1886
1275
  return
1887
1276
 
1888
- def _itemTransform(self, isFile: bool, isFolder: bool, hasChild: bool) -> None:
1277
+ def _itemTransform(self, isFile: bool, isFolder: bool) -> None:
1889
1278
  """Add actions for the Transform menu."""
1890
1279
  menu = self.addMenu(self.tr("Transform ..."))
1891
1280
 
1892
- tree = self.projTree
1893
- tHandle = self._handle
1894
-
1895
1281
  trDoc = trConst(nwLabels.LAYOUT_NAME[nwItemLayout.DOCUMENT])
1896
1282
  trNote = trConst(nwLabels.LAYOUT_NAME[nwItemLayout.NOTE])
1897
1283
  loDoc = nwItemLayout.DOCUMENT
@@ -1901,159 +1287,128 @@ class _TreeContextMenu(QMenu):
1901
1287
 
1902
1288
  if isNoteFile and self._item.documentAllowed():
1903
1289
  action = menu.addAction(self.tr("Convert to {0}").format(trDoc))
1904
- action.triggered.connect(lambda: self._changeItemLayout(loDoc))
1290
+ action.triggered.connect(qtLambda(self._changeItemLayout, loDoc))
1905
1291
 
1906
1292
  if isDocFile:
1907
1293
  action = menu.addAction(self.tr("Convert to {0}").format(trNote))
1908
- action.triggered.connect(lambda: self._changeItemLayout(loNote))
1294
+ action.triggered.connect(qtLambda(self._changeItemLayout, loNote))
1909
1295
 
1910
1296
  if isFolder and self._item.documentAllowed():
1911
1297
  action = menu.addAction(self.tr("Convert to {0}").format(trDoc))
1912
- action.triggered.connect(lambda: self._covertFolderToFile(loDoc))
1298
+ action.triggered.connect(qtLambda(self._convertFolderToFile, loDoc))
1913
1299
 
1914
1300
  if isFolder:
1915
1301
  action = menu.addAction(self.tr("Convert to {0}").format(trNote))
1916
- action.triggered.connect(lambda: self._covertFolderToFile(loNote))
1302
+ action.triggered.connect(qtLambda(self._convertFolderToFile, loNote))
1917
1303
 
1918
- if hasChild and isFile:
1304
+ if self._children and isFile:
1919
1305
  action = menu.addAction(self.tr("Merge Child Items into Self"))
1920
- action.triggered.connect(lambda: tree._mergeDocuments(tHandle, False))
1306
+ action.triggered.connect(qtLambda(self._tree.mergeDocuments, self._handle, False))
1921
1307
  action = menu.addAction(self.tr("Merge Child Items into New"))
1922
- action.triggered.connect(lambda: tree._mergeDocuments(tHandle, True))
1308
+ action.triggered.connect(qtLambda(self._tree.mergeDocuments, self._handle, True))
1923
1309
 
1924
- if hasChild and isFolder:
1310
+ if self._children and isFolder:
1925
1311
  action = menu.addAction(self.tr("Merge Documents in Folder"))
1926
- action.triggered.connect(lambda: tree._mergeDocuments(tHandle, True))
1312
+ action.triggered.connect(qtLambda(self._tree.mergeDocuments, self._handle, True))
1927
1313
 
1928
1314
  if isFile:
1929
1315
  action = menu.addAction(self.tr("Split Document by Headings"))
1930
- action.triggered.connect(lambda: tree._splitDocument(tHandle))
1316
+ action.triggered.connect(qtLambda(self._tree.splitDocument, self._handle))
1931
1317
 
1932
1318
  return
1933
1319
 
1934
- def _itemProcess(self, isFile: bool, isFolder: bool, isRoot: bool, hasChild: bool) -> None:
1935
- """Add actions for item processing."""
1936
- tree = self.projTree
1937
- tHandle = self._handle
1938
- if hasChild:
1939
- action = self.addAction(self.tr("Expand All"))
1940
- action.triggered.connect(lambda: tree.setExpandedFromHandle(tHandle, True))
1941
- action = self.addAction(self.tr("Collapse All"))
1942
- action.triggered.connect(lambda: tree.setExpandedFromHandle(tHandle, False))
1943
-
1944
- action = self.addAction(self.tr("Duplicate"))
1945
- action.triggered.connect(lambda: tree._duplicateFromHandle(tHandle))
1946
-
1947
- if self._item.itemClass == nwItemClass.TRASH or isRoot or (isFolder and not hasChild):
1948
- action = self.addAction(self.tr("Delete Permanently"))
1949
- action.triggered.connect(lambda: tree.permDeleteItem(tHandle))
1950
- else:
1951
- action = self.addAction(self.tr("Move to Trash"))
1952
- action.triggered.connect(lambda: tree.moveItemToTrash(tHandle))
1953
-
1320
+ def _expandCollapse(self) -> None:
1321
+ """Add actions for expand and collapse."""
1322
+ action = self.addAction(self.tr("Expand All"))
1323
+ action.triggered.connect(qtLambda(self._tree.expandFromIndex, self._indices[0]))
1324
+ action = self.addAction(self.tr("Collapse All"))
1325
+ action.triggered.connect(qtLambda(self._tree.collapseFromIndex, self._indices[0]))
1954
1326
  return
1955
1327
 
1956
- def _multiMoveToTrash(self) -> None:
1328
+ def _deleteOrTrash(self) -> None:
1957
1329
  """Add move to Trash action."""
1958
- areTrash = [i.itemClass == nwItemClass.TRASH for i in self._items]
1959
- if all(areTrash):
1960
- action = self.addAction(self.tr("Delete Permanently"))
1961
- action.triggered.connect(self._iterPermDelete)
1962
- elif not any(areTrash):
1963
- action = self.addAction(self.tr("Move to Trash"))
1964
- action.triggered.connect(self._iterMoveToTrash)
1330
+ if (
1331
+ self._model.trashSelection(self._indices)
1332
+ or len(self._indices) == 1 and self._item.isRootType()
1333
+ ):
1334
+ text = self.tr("Delete Permanently")
1335
+ else:
1336
+ text = self.tr("Move to Trash")
1337
+ action = self.addAction(text)
1338
+ action.triggered.connect(self._tree.processDeleteRequest)
1965
1339
  return
1966
1340
 
1967
1341
  ##
1968
1342
  # Private Slots
1969
1343
  ##
1970
1344
 
1971
- @pyqtSlot()
1972
- def _iterMoveToTrash(self) -> None:
1973
- """Iterate through files and move them to Trash."""
1974
- if SHARED.question(self.tr("Move {0} items to Trash?").format(len(self._items))):
1975
- for tItem in self._items:
1976
- if tItem.isFileType() and tItem.itemClass != nwItemClass.TRASH:
1977
- self.projTree.moveItemToTrash(tItem.itemHandle, askFirst=False, flush=False)
1978
- self.projTree.saveTreeOrder()
1979
- return
1980
-
1981
- @pyqtSlot()
1982
- def _iterPermDelete(self) -> None:
1983
- """Iterate through files and delete them."""
1984
- if SHARED.question(self.projTree.trPermDelete.format(len(self._items))):
1985
- for tItem in self._items:
1986
- if tItem.isFileType() and tItem.itemClass == nwItemClass.TRASH:
1987
- self.projTree.permDeleteItem(tItem.itemHandle, askFirst=False, flush=False)
1988
- self.projTree.saveTreeOrder()
1989
- return
1990
-
1991
1345
  @pyqtSlot()
1992
1346
  def _toggleItemActive(self) -> None:
1993
1347
  """Toggle the active status of an item."""
1994
- self._item.setActive(not self._item.isActive)
1995
- self.projTree.setTreeItemValues(self._item)
1996
- self.projTree._alertTreeChange(self._handle, flush=False)
1348
+ if self._item.isFileType():
1349
+ self._item.setActive(not self._item.isActive)
1350
+ self._item.notifyToRefresh()
1997
1351
  return
1998
1352
 
1999
1353
  ##
2000
1354
  # Internal Functions
2001
1355
  ##
2002
1356
 
2003
- def _iterItemActive(self, isActive: bool) -> None:
1357
+ def _iterItemActive(self, state: bool) -> None:
2004
1358
  """Set the active status of multiple items."""
2005
- for tItem in self._items:
2006
- if tItem and tItem.isFileType():
2007
- tItem.setActive(isActive)
2008
- self.projTree.setTreeItemValues(tItem)
2009
- self.projTree._alertTreeChange(tItem.itemHandle, flush=False)
1359
+ refresh = []
1360
+ for node in self._model.nodes(self._indices):
1361
+ if node.item.isFileType():
1362
+ node.item.setActive(state)
1363
+ refresh.append(node.item.itemHandle)
1364
+ SHARED.project.tree.refreshItems(refresh)
2010
1365
  return
2011
1366
 
2012
1367
  def _changeItemStatus(self, key: str) -> None:
2013
1368
  """Set a new status value of an item."""
2014
- self._item.setStatus(key)
2015
- self.projTree.setTreeItemValues(self._item)
2016
- self.projTree._alertTreeChange(self._handle, flush=False)
1369
+ if self._item.isFileType():
1370
+ self._item.setStatus(key)
1371
+ self._item.notifyToRefresh()
2017
1372
  return
2018
1373
 
2019
1374
  def _iterSetItemStatus(self, key: str) -> None:
2020
1375
  """Change the status value for multiple items."""
2021
- for tItem in self._items:
2022
- if tItem and tItem.isNovelLike():
2023
- tItem.setStatus(key)
2024
- self.projTree.setTreeItemValues(tItem)
2025
- self.projTree._alertTreeChange(tItem.itemHandle, flush=False)
1376
+ refresh = []
1377
+ for node in self._model.nodes(self._indices):
1378
+ if node.item.isNovelLike():
1379
+ node.item.setStatus(key)
1380
+ refresh.append(node.item.itemHandle)
1381
+ SHARED.project.tree.refreshItems(refresh)
2026
1382
  return
2027
1383
 
2028
1384
  def _changeItemImport(self, key: str) -> None:
2029
1385
  """Set a new importance value of an item."""
2030
- self._item.setImport(key)
2031
- self.projTree.setTreeItemValues(self._item)
2032
- self.projTree._alertTreeChange(self._handle, flush=False)
1386
+ if self._item.isFileType():
1387
+ self._item.setImport(key)
1388
+ self._item.notifyToRefresh()
2033
1389
  return
2034
1390
 
2035
1391
  def _iterSetItemImport(self, key: str) -> None:
2036
1392
  """Change the status value for multiple items."""
2037
- for tItem in self._items:
2038
- if tItem and not tItem.isNovelLike():
2039
- tItem.setImport(key)
2040
- self.projTree.setTreeItemValues(tItem)
2041
- self.projTree._alertTreeChange(tItem.itemHandle, flush=False)
1393
+ refresh = []
1394
+ for node in self._model.nodes(self._indices):
1395
+ if not node.item.isNovelLike():
1396
+ node.item.setImport(key)
1397
+ refresh.append(node.item.itemHandle)
1398
+ SHARED.project.tree.refreshItems(refresh)
2042
1399
  return
2043
1400
 
2044
1401
  def _changeItemLayout(self, itemLayout: nwItemLayout) -> None:
2045
1402
  """Set a new item layout value of an item."""
2046
1403
  if itemLayout == nwItemLayout.DOCUMENT and self._item.documentAllowed():
2047
1404
  self._item.setLayout(nwItemLayout.DOCUMENT)
2048
- self.projTree.setTreeItemValues(self._item)
2049
- self.projTree._alertTreeChange(self._handle, flush=False)
1405
+ self._item.notifyToRefresh()
2050
1406
  elif itemLayout == nwItemLayout.NOTE:
2051
1407
  self._item.setLayout(nwItemLayout.NOTE)
2052
- self.projTree.setTreeItemValues(self._item)
2053
- self.projTree._alertTreeChange(self._handle, flush=False)
1408
+ self._item.notifyToRefresh()
2054
1409
  return
2055
1410
 
2056
- def _covertFolderToFile(self, itemLayout: nwItemLayout) -> None:
1411
+ def _convertFolderToFile(self, itemLayout: nwItemLayout) -> None:
2057
1412
  """Convert a folder to a note or document."""
2058
1413
  if self._item.isFolderType():
2059
1414
  msgYes = SHARED.question(self.tr(
@@ -2063,13 +1418,11 @@ class _TreeContextMenu(QMenu):
2063
1418
  if msgYes and itemLayout == nwItemLayout.DOCUMENT and self._item.documentAllowed():
2064
1419
  self._item.setType(nwItemType.FILE)
2065
1420
  self._item.setLayout(nwItemLayout.DOCUMENT)
2066
- self.projTree.setTreeItemValues(self._item)
2067
- self.projTree._alertTreeChange(self._handle, flush=False)
1421
+ self._item.notifyToRefresh()
2068
1422
  elif msgYes and itemLayout == nwItemLayout.NOTE:
2069
1423
  self._item.setType(nwItemType.FILE)
2070
1424
  self._item.setLayout(nwItemLayout.NOTE)
2071
- self.projTree.setTreeItemValues(self._item)
2072
- self.projTree._alertTreeChange(self._handle, flush=False)
1425
+ self._item.notifyToRefresh()
2073
1426
  else:
2074
1427
  logger.info("Folder conversion cancelled")
2075
1428
  return