novelWriter 2.5.2__py3-none-any.whl → 2.6__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 (129) hide show
  1. {novelWriter-2.5.2.dist-info → novelWriter-2.6.dist-info}/METADATA +5 -4
  2. {novelWriter-2.5.2.dist-info → novelWriter-2.6.dist-info}/RECORD +126 -105
  3. {novelWriter-2.5.2.dist-info → novelWriter-2.6.dist-info}/WHEEL +1 -1
  4. novelwriter/__init__.py +50 -11
  5. novelwriter/assets/i18n/nw_de_DE.qm +0 -0
  6. novelwriter/assets/i18n/nw_en_US.qm +0 -0
  7. novelwriter/assets/i18n/nw_es_419.qm +0 -0
  8. novelwriter/assets/i18n/nw_fr_FR.qm +0 -0
  9. novelwriter/assets/i18n/nw_it_IT.qm +0 -0
  10. novelwriter/assets/i18n/nw_ja_JP.qm +0 -0
  11. novelwriter/assets/i18n/nw_nb_NO.qm +0 -0
  12. novelwriter/assets/i18n/nw_nl_NL.qm +0 -0
  13. novelwriter/assets/i18n/nw_pl_PL.qm +0 -0
  14. novelwriter/assets/i18n/nw_pt_BR.qm +0 -0
  15. novelwriter/assets/i18n/nw_ru_RU.qm +0 -0
  16. novelwriter/assets/i18n/nw_zh_CN.qm +0 -0
  17. novelwriter/assets/i18n/project_de_DE.json +4 -2
  18. novelwriter/assets/i18n/project_en_GB.json +1 -0
  19. novelwriter/assets/i18n/project_en_US.json +2 -0
  20. novelwriter/assets/i18n/project_it_IT.json +2 -0
  21. novelwriter/assets/i18n/project_ja_JP.json +2 -0
  22. novelwriter/assets/i18n/project_nb_NO.json +2 -0
  23. novelwriter/assets/i18n/project_nl_NL.json +2 -0
  24. novelwriter/assets/i18n/project_pl_PL.json +2 -0
  25. novelwriter/assets/i18n/project_pt_BR.json +2 -0
  26. novelwriter/assets/i18n/project_ru_RU.json +11 -0
  27. novelwriter/assets/i18n/project_zh_CN.json +2 -0
  28. novelwriter/assets/icons/typicons_dark/icons.conf +8 -0
  29. novelwriter/assets/icons/typicons_dark/mixed_copy.svg +4 -0
  30. novelwriter/assets/icons/typicons_dark/mixed_margin-bottom.svg +6 -0
  31. novelwriter/assets/icons/typicons_dark/mixed_margin-left.svg +6 -0
  32. novelwriter/assets/icons/typicons_dark/mixed_margin-right.svg +6 -0
  33. novelwriter/assets/icons/typicons_dark/mixed_margin-top.svg +6 -0
  34. novelwriter/assets/icons/typicons_dark/mixed_size-height.svg +6 -0
  35. novelwriter/assets/icons/typicons_dark/mixed_size-width.svg +6 -0
  36. novelwriter/assets/icons/typicons_dark/nw_toolbar.svg +5 -0
  37. novelwriter/assets/icons/typicons_light/icons.conf +8 -0
  38. novelwriter/assets/icons/typicons_light/mixed_copy.svg +4 -0
  39. novelwriter/assets/icons/typicons_light/mixed_margin-bottom.svg +6 -0
  40. novelwriter/assets/icons/typicons_light/mixed_margin-left.svg +6 -0
  41. novelwriter/assets/icons/typicons_light/mixed_margin-right.svg +6 -0
  42. novelwriter/assets/icons/typicons_light/mixed_margin-top.svg +6 -0
  43. novelwriter/assets/icons/typicons_light/mixed_size-height.svg +6 -0
  44. novelwriter/assets/icons/typicons_light/mixed_size-width.svg +6 -0
  45. novelwriter/assets/icons/typicons_light/nw_toolbar.svg +5 -0
  46. novelwriter/assets/manual.pdf +0 -0
  47. novelwriter/assets/sample.zip +0 -0
  48. novelwriter/assets/text/credits_en.htm +1 -0
  49. novelwriter/assets/themes/default_light.conf +2 -2
  50. novelwriter/common.py +101 -3
  51. novelwriter/config.py +30 -17
  52. novelwriter/constants.py +189 -81
  53. novelwriter/core/buildsettings.py +74 -40
  54. novelwriter/core/coretools.py +146 -148
  55. novelwriter/core/docbuild.py +133 -171
  56. novelwriter/core/document.py +1 -1
  57. novelwriter/core/index.py +39 -38
  58. novelwriter/core/item.py +42 -9
  59. novelwriter/core/itemmodel.py +518 -0
  60. novelwriter/core/options.py +5 -2
  61. novelwriter/core/project.py +68 -90
  62. novelwriter/core/projectdata.py +8 -2
  63. novelwriter/core/projectxml.py +1 -1
  64. novelwriter/core/sessions.py +1 -1
  65. novelwriter/core/spellcheck.py +10 -15
  66. novelwriter/core/status.py +24 -8
  67. novelwriter/core/storage.py +1 -1
  68. novelwriter/core/tree.py +269 -288
  69. novelwriter/dialogs/about.py +1 -1
  70. novelwriter/dialogs/docmerge.py +8 -18
  71. novelwriter/dialogs/docsplit.py +1 -1
  72. novelwriter/dialogs/editlabel.py +1 -1
  73. novelwriter/dialogs/preferences.py +47 -34
  74. novelwriter/dialogs/projectsettings.py +149 -99
  75. novelwriter/dialogs/quotes.py +1 -1
  76. novelwriter/dialogs/wordlist.py +11 -10
  77. novelwriter/enum.py +37 -24
  78. novelwriter/error.py +2 -2
  79. novelwriter/extensions/configlayout.py +28 -13
  80. novelwriter/extensions/eventfilters.py +1 -1
  81. novelwriter/extensions/modified.py +30 -6
  82. novelwriter/extensions/novelselector.py +4 -3
  83. novelwriter/extensions/pagedsidebar.py +9 -9
  84. novelwriter/extensions/progressbars.py +4 -4
  85. novelwriter/extensions/statusled.py +3 -3
  86. novelwriter/extensions/switch.py +3 -3
  87. novelwriter/extensions/switchbox.py +1 -1
  88. novelwriter/extensions/versioninfo.py +1 -1
  89. novelwriter/formats/shared.py +156 -0
  90. novelwriter/formats/todocx.py +1191 -0
  91. novelwriter/formats/tohtml.py +454 -0
  92. novelwriter/{core → formats}/tokenizer.py +497 -495
  93. novelwriter/formats/tomarkdown.py +218 -0
  94. novelwriter/{core → formats}/toodt.py +312 -433
  95. novelwriter/formats/toqdoc.py +486 -0
  96. novelwriter/formats/toraw.py +91 -0
  97. novelwriter/gui/doceditor.py +347 -287
  98. novelwriter/gui/dochighlight.py +97 -85
  99. novelwriter/gui/docviewer.py +90 -33
  100. novelwriter/gui/docviewerpanel.py +18 -26
  101. novelwriter/gui/editordocument.py +18 -3
  102. novelwriter/gui/itemdetails.py +27 -29
  103. novelwriter/gui/mainmenu.py +130 -64
  104. novelwriter/gui/noveltree.py +46 -48
  105. novelwriter/gui/outline.py +202 -256
  106. novelwriter/gui/projtree.py +590 -1238
  107. novelwriter/gui/search.py +11 -19
  108. novelwriter/gui/sidebar.py +8 -7
  109. novelwriter/gui/statusbar.py +20 -3
  110. novelwriter/gui/theme.py +11 -6
  111. novelwriter/guimain.py +101 -201
  112. novelwriter/shared.py +67 -28
  113. novelwriter/text/counting.py +3 -1
  114. novelwriter/text/patterns.py +169 -61
  115. novelwriter/tools/dictionaries.py +3 -3
  116. novelwriter/tools/lipsum.py +1 -1
  117. novelwriter/tools/manusbuild.py +15 -13
  118. novelwriter/tools/manuscript.py +121 -79
  119. novelwriter/tools/manussettings.py +424 -291
  120. novelwriter/tools/noveldetails.py +1 -1
  121. novelwriter/tools/welcome.py +6 -6
  122. novelwriter/tools/writingstats.py +4 -4
  123. novelwriter/types.py +25 -9
  124. novelwriter/core/tohtml.py +0 -530
  125. novelwriter/core/tomarkdown.py +0 -252
  126. novelwriter/core/toqdoc.py +0 -419
  127. {novelWriter-2.5.2.dist-info → novelWriter-2.6.dist-info}/LICENSE.md +0 -0
  128. {novelWriter-2.5.2.dist-info → novelWriter-2.6.dist-info}/entry_points.txt +0 -0
  129. {novelWriter-2.5.2.dist-info → novelWriter-2.6.dist-info}/top_level.txt +0 -0
@@ -3,13 +3,15 @@ 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
- Copyright 2018–2024, Veronica Berglyd Olsen
14
+ Copyright (C) 2018 Veronica Berglyd Olsen and novelWriter contributors
13
15
 
14
16
  This program is free software: you can redistribute it and/or modify
15
17
  it under the terms of the GNU General Public License as published by
@@ -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,1039 +606,435 @@ 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
- trItemP = trItemS.parent()
1004
- tIndex = trItemP.indexOfChild(trItemS)
1005
- trItemP.takeChild(tIndex)
765
+ docSplit.setParentItem(tItem.itemParent)
1006
766
 
1007
- for dHandle in reversed(self.getTreeFromHandle(tHandle)):
1008
- SHARED.closeEditor(dHandle)
1009
- SHARED.project.removeItem(dHandle)
1010
- self._treeMap.pop(dHandle, None)
1011
-
1012
- self._alertTreeChange(tHandle, flush=flush)
1013
- 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
+ )
1014
774
 
1015
- # This is not emitted by the alert function because the item
1016
- # has already been deleted
1017
- self.projView.treeItemChanged.emit(tHandle)
775
+ if data.get("moveToTrash", False):
776
+ self.processDeleteRequest([tHandle], False)
1018
777
 
1019
778
  return True
1020
779
 
1021
- def refreshUserLabels(self, kind: str) -> None:
1022
- """Refresh status or importance labels."""
1023
- if kind == "s":
1024
- for nwItem in SHARED.project.tree:
1025
- if nwItem.isNovelLike():
1026
- self.setTreeItemValues(nwItem)
1027
- elif kind == "i":
1028
- for nwItem in SHARED.project.tree:
1029
- if not nwItem.isNovelLike():
1030
- self.setTreeItemValues(nwItem)
1031
- return
1032
-
1033
- def setTreeItemValues(self, nwItem: NWItem | None) -> None:
1034
- """Set the name and flag values for a tree item in the project
1035
- tree. Does not trigger a tree change as the data is already
1036
- coming from project data.
1037
- """
1038
- if isinstance(nwItem, NWItem) and (trItem := self._getTreeItem(nwItem.itemHandle)):
1039
- itemStatus, statusIcon = nwItem.getImportStatus()
1040
- hLevel = nwItem.mainHeading
1041
- itemIcon = SHARED.theme.getItemIcon(
1042
- nwItem.itemType, nwItem.itemClass, nwItem.itemLayout, hLevel
1043
- )
1044
-
1045
- trItem.setIcon(self.C_NAME, itemIcon)
1046
- trItem.setText(self.C_NAME, nwItem.itemName)
1047
- trItem.setIcon(self.C_STATUS, statusIcon)
1048
- trItem.setToolTip(self.C_STATUS, itemStatus)
1049
-
1050
- if nwItem.isFileType():
1051
- iconName = "checked" if nwItem.isActive else "unchecked"
1052
- toolTip = self.trActive if nwItem.isActive else self.trInactive
1053
- 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?")
1054
787
  else:
1055
- iconName = "noncheckable"
1056
-
1057
- trItem.setIcon(self.C_ACTIVE, SHARED.theme.getIcon(iconName))
1058
-
1059
- if CONFIG.emphLabels and nwItem.isDocumentLayout():
1060
- trFont = trItem.font(self.C_NAME)
1061
- trFont.setBold(hLevel == "H1" or hLevel == "H2")
1062
- trFont.setUnderline(hLevel == "H1")
1063
- trItem.setFont(self.C_NAME, trFont)
1064
-
1065
- # Emit Refresh Signal
1066
- self.itemRefreshed.emit(nwItem.itemHandle, nwItem, itemIcon)
1067
-
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()
1068
795
  return
1069
796
 
1070
- def propagateCount(self, tHandle: str, newCount: int, countChildren: bool = False) -> None:
1071
- """Recursive function setting the word count for a given item,
1072
- and propagating that count upwards in the tree until reaching a
1073
- root item. This function is more efficient than recalculating
1074
- everything each time the word count is updated, but is also
1075
- prone to diverging from the true values if the counts are not
1076
- properly reported to the function.
1077
- """
1078
- tItem = self._getTreeItem(tHandle)
1079
- if tItem is None:
1080
- return
1081
-
1082
- if countChildren:
1083
- for i in range(tItem.childCount()):
1084
- newCount += int(tItem.child(i).data(self.C_DATA, self.D_WORDS))
1085
-
1086
- tItem.setText(self.C_COUNT, f"{newCount:n}")
1087
- tItem.setData(self.C_DATA, self.D_WORDS, int(newCount))
1088
-
1089
- pItem = tItem.parent()
1090
- if pItem is None:
1091
- return
1092
-
1093
- pCount = 0
1094
- pHandle = None
1095
- for i in range(pItem.childCount()):
1096
- pCount += int(pItem.child(i).data(self.C_DATA, self.D_WORDS))
1097
- pHandle = pItem.data(self.C_DATA, self.D_HANDLE)
1098
-
1099
- if pHandle:
1100
- if SHARED.project.tree.checkType(pHandle, nwItemType.FILE):
1101
- # A file has an internal word count we need to account
1102
- # for, but a folder always has 0 words on its own.
1103
- pCount += SHARED.project.index.getCounts(pHandle)[1]
1104
-
1105
- self.propagateCount(pHandle, pCount, countChildren=False)
1106
-
1107
- return
797
+ ##
798
+ # Events and Overloads
799
+ ##
1108
800
 
1109
- def buildTree(self) -> None:
1110
- """Build the entire project tree from scratch. This depends on
1111
- the save project item iterator in the project class which will
1112
- always make sure items with a parent have had their parent item
1113
- 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.
1114
805
  """
1115
- logger.debug("Building the project tree ...")
1116
- self.clearTree()
1117
- count = 0
1118
- for nwItem in SHARED.project.iterProjectItems():
1119
- count += 1
1120
- self._addTreeItem(nwItem)
1121
- if count > 0:
1122
- 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
+ )
1123
815
  return
1124
816
 
1125
- def getSelectedHandle(self) -> str | None:
1126
- """Get the currently selected handle. If multiple items are
1127
- selected, return the first.
1128
- """
1129
- if items := self.selectedItems():
1130
- return items[0].data(self.C_DATA, self.D_HANDLE)
1131
- return None
1132
-
1133
- def setSelectedHandle(self, tHandle: str | None, doScroll: bool = False) -> bool:
1134
- """Set a specific handle as the selected item."""
1135
- tItem = self._getTreeItem(tHandle)
1136
- if tItem is None:
1137
- return False
1138
-
1139
- if tHandle in self._treeMap:
1140
- self.setCurrentItem(self._treeMap[tHandle])
1141
-
1142
- if (indexes := self.selectedIndexes()) and doScroll:
1143
- self.scrollTo(indexes[0], QAbstractItemView.ScrollHint.PositionAtCenter)
1144
-
1145
- return True
1146
-
1147
- def setExpandedFromHandle(self, tHandle: str | None, isExpanded: bool) -> None:
1148
- """Iterate through items below tHandle and change expanded
1149
- status for all child items. If tHandle is None, it affects the
1150
- entire tree.
1151
- """
1152
- trItem = self._getTreeItem(tHandle) or self.invisibleRootItem()
1153
- 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)
1154
822
  return
1155
823
 
1156
- def openContextOnSelected(self) -> bool:
1157
- """Open the context menu on the current selected item."""
1158
- if items := self.selectedItems():
1159
- return self._openContextMenu(self.visualItemRect(items[0]).center())
1160
- return False
1161
-
1162
- def changedSince(self, checkTime: float) -> bool:
1163
- """Check if the tree has changed since a given time."""
1164
- return self._timeChanged > checkTime
1165
-
1166
824
  ##
1167
- # Private Slots
825
+ # Public Slots
1168
826
  ##
1169
827
 
1170
828
  @pyqtSlot()
1171
- def _treeSelectionChange(self) -> None:
1172
- """The user changed which item is selected."""
1173
- tHandle = self.getSelectedHandle()
1174
- if tHandle is not None:
1175
- self.projView.selectedItemChanged.emit(tHandle)
1176
-
1177
- # When selecting multiple items, don't allow including root
1178
- # items in the selection and instead deselect them
1179
- items = self.selectedItems()
1180
- if items and len(items) > 1:
1181
- for item in items:
1182
- if item.parent() is None:
1183
- item.setSelected(False)
1184
-
829
+ def moveItemUp(self) -> None:
830
+ """Move an item up in the tree."""
831
+ if model := self._getModel():
832
+ model.internalMove(self.currentIndex(), -1)
1185
833
  return
1186
834
 
1187
- @pyqtSlot("QTreeWidgetItem*", int)
1188
- def _treeDoubleClick(self, trItem: QTreeWidgetItem, colNo: int) -> None:
1189
- """Capture a double-click event and either request the document
1190
- for editing if it is a file, or expand/close the node it is not.
1191
- """
1192
- tHandle = self.getSelectedHandle()
1193
- if tHandle is None:
1194
- return
1195
-
1196
- tItem = SHARED.project.tree[tHandle]
1197
- if tItem is None:
1198
- return
1199
-
1200
- if tItem.isFileType():
1201
- self.projView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, "", True)
1202
- else:
1203
- trItem.setExpanded(not trItem.isExpanded())
1204
-
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)
1205
840
  return
1206
841
 
1207
- @pyqtSlot("QPoint")
1208
- def _openContextMenu(self, clickPos: QPoint) -> bool:
1209
- """The user right clicked an element in the project tree, so we
1210
- open a context menu in-place.
1211
- """
1212
- tItem = None
1213
- tHandle = None
1214
- hasChild = False
1215
- sItem = self.itemAt(clickPos)
1216
- sItems = self.selectedItems()
1217
- if isinstance(sItem, QTreeWidgetItem):
1218
- tHandle = sItem.data(self.C_DATA, self.D_HANDLE)
1219
- tItem = SHARED.project.tree[tHandle]
1220
- hasChild = sItem.childCount() > 0
1221
-
1222
- if tItem is None or tHandle is None:
1223
- logger.debug("No item found")
1224
- return False
1225
-
1226
- ctxMenu = _TreeContextMenu(self, tItem)
1227
- trashHandle = SHARED.project.tree.trashRoot
1228
- if trashHandle and tHandle == trashHandle:
1229
- ctxMenu.buildTrashMenu()
1230
- elif len(sItems) > 1:
1231
- handles = [str(x.data(self.C_DATA, self.D_HANDLE)) for x in sItems]
1232
- ctxMenu.buildMultiSelectMenu(handles)
1233
- else:
1234
- ctxMenu.buildSingleSelectMenu(hasChild)
1235
-
1236
- ctxMenu.exec(self.viewport().mapToGlobal(clickPos))
1237
- ctxMenu.deleteLater()
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))
848
+ return
1238
849
 
1239
- return True
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
1240
857
 
1241
858
  @pyqtSlot()
1242
- def _doAutoScroll(self) -> None:
1243
- """Scroll one item up or down based on direction value."""
1244
- if self._scrollDirection == -1:
1245
- self.scrollToItem(self.itemAbove(self.itemAt(1, 1)))
1246
- elif self._scrollDirection == 1:
1247
- self.scrollToItem(self.itemBelow(self.itemAt(1, self.height() - 1)))
1248
- self._scrollDirection = 0
1249
- self._scrollTimer.stop()
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))
1250
867
  return
1251
868
 
1252
- ##
1253
- # Events
1254
- ##
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
1255
879
 
1256
- def mousePressEvent(self, event: QMouseEvent) -> None:
1257
- """Overload mousePressEvent to clear selection if clicking the
1258
- mouse in a blank area of the tree view, and to load a document
1259
- for viewing if the user middle-clicked.
1260
- """
1261
- super().mousePressEvent(event)
1262
- if event.button() == QtMouseLeft:
1263
- selItem = self.indexAt(event.pos())
1264
- if not selItem.isValid():
1265
- self.clearSelection()
1266
- elif event.button() == QtMouseMiddle:
1267
- selItem = self.itemAt(event.pos())
1268
- if selItem:
1269
- tHandle = selItem.data(self.C_DATA, self.D_HANDLE)
1270
- if (tItem := SHARED.project.tree[tHandle]) and tItem.isFileType():
1271
- self.projView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, "", False)
880
+ @pyqtSlot(QModelIndex)
881
+ def expandFromIndex(self, index: QModelIndex) -> None:
882
+ """Expand all nodes from index."""
883
+ self.expandRecursively(index)
1272
884
  return
1273
885
 
1274
- def startDrag(self, dropAction: Qt.DropAction) -> None:
1275
- """Capture the drag and drop handling to pop alerts."""
1276
- super().startDrag(dropAction)
1277
- if self._popAlert:
1278
- SHARED.error(self._popAlert)
1279
- self._popAlert = None
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)
1280
892
  return
1281
893
 
1282
- def dragEnterEvent(self, event: QDragEnterEvent) -> None:
1283
- """Check that we're only dragging items that are siblings, and
1284
- not a root level item.
1285
- """
1286
- items = self.selectedItems()
1287
- if items and (parent := items[0].parent()) and all(x.parent() is parent for x in items):
1288
- super().dragEnterEvent(event)
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]
1289
901
  else:
1290
- logger.warning("Drag action is not allowed and has been cancelled")
1291
- self._popAlert = self.tr(
1292
- "Drag and drop is only allowed for single items, non-root "
1293
- "items, or multiple items with the same parent."
1294
- )
1295
- event.mimeData().clear()
1296
- event.ignore()
1297
- return
1298
-
1299
- def dragMoveEvent(self, event: QDragMoveEvent) -> None:
1300
- """Capture the drag move event to enable edge auto scroll."""
1301
- y = event.pos().y()
1302
- if y < self._scrollMargin:
1303
- if not self._scrollTimer.isActive():
1304
- self._scrollDirection = -1
1305
- self._scrollTimer.start()
1306
- elif y > self.height() - self._scrollMargin:
1307
- if not self._scrollTimer.isActive():
1308
- self._scrollDirection = 1
1309
- self._scrollTimer.start()
1310
- super().dragMoveEvent(event)
1311
- return
1312
-
1313
- def dropEvent(self, event: QDropEvent) -> None:
1314
- """Overload the drop item event to ensure the drag and drop
1315
- action is allowed, and update relevant data.
1316
- """
1317
- tItem = self.itemAt(event.pos())
1318
- dropOn = self.dropIndicatorPosition() == QAbstractItemView.DropIndicatorPosition.OnItem
1319
- # Make sure nothing can be dropped on invisible root (see #1569)
1320
- if not tItem or tItem.parent() is None and not dropOn:
1321
- logger.error("Invalid drop location")
1322
- event.ignore()
1323
- return
902
+ indices = self._selectedRows()
1324
903
 
1325
- mItems: dict[str, tuple[QTreeWidgetItem, bool]] = {}
1326
- sItems = self.selectedItems()
1327
- if sItems and (parent := sItems[0].parent()) and all(x.parent() is parent for x in sItems):
1328
- for sItem in sItems:
1329
- mHandle = str(sItem.data(self.C_DATA, self.D_HANDLE))
1330
- mItems[mHandle] = (sItem, sItem.isExpanded())
1331
- self.propagateCount(mHandle, 0)
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
1332
911
 
1333
- super().dropEvent(event)
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))
1334
927
 
1335
- for mHandle, (sItem, isExpanded) in mItems.items():
1336
- self._postItemMove(mHandle)
1337
- sItem.setExpanded(isExpanded)
1338
- self._alertTreeChange(mHandle, flush=False)
928
+ return
1339
929
 
1340
- self.saveTreeOrder()
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.
935
+ """
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)
947
+ return
1341
948
 
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.
954
+ """
955
+ if model := self._getModel():
956
+ if point is None:
957
+ point = self.visualRect(self.currentIndex()).center()
958
+ if point is not None:
959
+ index = self.indexAt(point)
960
+ if (node := self._getNode(index)) and (indices := self._selectedRows()):
961
+ ctxMenu = _TreeContextMenu(self, model, node, indices)
962
+ if node is SHARED.project.tree.trash:
963
+ ctxMenu.buildTrashMenu()
964
+ elif len(indices) > 1:
965
+ ctxMenu.buildMultiSelectMenu()
966
+ else:
967
+ ctxMenu.buildSingleSelectMenu()
968
+ ctxMenu.exec(self.viewport().mapToGlobal(point))
969
+ ctxMenu.setParent(None)
1342
970
  return
1343
971
 
1344
972
  ##
1345
- # Internal Functions
973
+ # Private Slots
1346
974
  ##
1347
975
 
1348
- def _postItemMove(self, tHandle: str) -> None:
1349
- """Run various maintenance tasks for a moved item."""
1350
- trItemS = self._getTreeItem(tHandle)
1351
- nwItemS = SHARED.project.tree[tHandle]
1352
- trItemP = trItemS.parent() if trItemS else None
1353
- if trItemP is None or nwItemS is None:
1354
- logger.error("Failed to find new parent item of '%s'", tHandle)
1355
- return
1356
-
1357
- # Update item parent handle in the project
1358
- pHandle = trItemP.data(self.C_DATA, self.D_HANDLE)
1359
- nwItemS.setParent(pHandle)
1360
- trItemP.setExpanded(True)
1361
- logger.debug("The parent of item '%s' has been changed to '%s'", tHandle, pHandle)
1362
-
1363
- mHandles = self.getTreeFromHandle(tHandle)
1364
- logger.debug("A total of %d item(s) were moved", len(mHandles))
1365
- for mHandle in mHandles:
1366
- logger.debug("Updating item '%s'", mHandle)
1367
- SHARED.project.tree.updateItemData(mHandle)
1368
- if nwItemS.isInactiveClass():
1369
- SHARED.project.index.deleteHandle(mHandle)
1370
- else:
1371
- SHARED.project.index.reIndexHandle(mHandle)
1372
- if mItem := SHARED.project.tree[mHandle]:
1373
- self.setTreeItemValues(mItem)
1374
-
1375
- # Update word count
1376
- self.propagateCount(tHandle, nwItemS.wordCount, countChildren=True)
1377
-
976
+ @pyqtSlot(QModelIndex)
977
+ def _onSingleClick(self, index: QModelIndex) -> None:
978
+ """The user changed which item is selected."""
979
+ if node := self._getNode(index):
980
+ self.projView.selectedItemChanged.emit(node.item.itemHandle)
1378
981
  return
1379
982
 
1380
- def _getItemWordCount(self, tHandle: str) -> int:
1381
- """Return the word count of a given item handle."""
1382
- tItem = self._getTreeItem(tHandle)
1383
- return int(tItem.data(self.C_DATA, self.D_WORDS)) if tItem else 0
1384
-
1385
- def _getTreeItem(self, tHandle: str | None) -> QTreeWidgetItem | None:
1386
- """Return the QTreeWidgetItem of a given item handle."""
1387
- return self._treeMap.get(tHandle, None) if tHandle else None
1388
-
1389
- def _recursiveSetExpanded(self, trItem: QTreeWidgetItem, isExpanded: bool) -> None:
1390
- """Recursive function to set expanded status starting from (and
1391
- not including) a given item.
983
+ @pyqtSlot(QModelIndex)
984
+ def _onDoubleClick(self, index: QModelIndex) -> None:
985
+ """Capture a double-click event and either request the document
986
+ for editing if it is a file, or expand/close the node if not.
1392
987
  """
1393
- if isinstance(trItem, QTreeWidgetItem):
1394
- for i in range(trItem.childCount()):
1395
- chItem = trItem.child(i)
1396
- chItem.setExpanded(isExpanded)
1397
- self._recursiveSetExpanded(chItem, isExpanded)
1398
- return
1399
-
1400
- def _mergeDocuments(self, tHandle: str, newFile: bool) -> bool:
1401
- """Merge an item's child documents into a single document."""
1402
- logger.info("Request to merge items under handle '%s'", tHandle)
1403
- itemList = self.getTreeFromHandle(tHandle)
1404
-
1405
- tItem = SHARED.project.tree[tHandle]
1406
- if tItem is None:
1407
- return False
1408
-
1409
- if tItem.isRootType():
1410
- logger.error("Cannot merge root item")
1411
- return False
1412
-
1413
- if not newFile:
1414
- itemList.remove(tHandle)
1415
-
1416
- data, status = GuiDocMerge.getData(SHARED.mainGui, tHandle, itemList)
1417
- if status:
1418
- items = data.get("finalItems", [])
1419
- if not items:
1420
- SHARED.info(self.tr("No documents selected for merging."))
1421
- return False
1422
-
1423
- # Save the open document first, in case it's part of merge
1424
- SHARED.saveEditor()
1425
-
1426
- # Create merge object, and append docs
1427
- docMerger = DocMerger(SHARED.project)
1428
- mLabel = self.tr("Merged")
1429
-
1430
- if newFile:
1431
- docLabel = f"[{mLabel}] {tItem.itemName}"
1432
- mHandle = docMerger.newTargetDoc(tHandle, docLabel)
1433
- elif tItem.isFileType():
1434
- docMerger.setTargetDoc(tHandle)
1435
- mHandle = tHandle
1436
- else:
1437
- return False
1438
-
1439
- for sHandle in items:
1440
- docMerger.appendText(sHandle, True, mLabel)
1441
-
1442
- if not docMerger.writeTargetDoc():
1443
- SHARED.error(
1444
- self.tr("Could not write document content."),
1445
- info=docMerger.getError()
988
+ if node := self._getNode(index):
989
+ if node.item.isFileType():
990
+ self.projView.openDocumentRequest.emit(
991
+ node.item.itemHandle, nwDocMode.EDIT, "", True
1446
992
  )
1447
- return False
1448
-
1449
- SHARED.project.index.reIndexHandle(mHandle)
1450
- if newFile:
1451
- self.revealNewTreeItem(mHandle, nHandle=tHandle, wordCount=True)
1452
-
1453
- self.projView.openDocumentRequest.emit(mHandle, nwDocMode.EDIT, "", False)
1454
- self.projView.setSelectedHandle(mHandle, doScroll=True)
1455
-
1456
- if data.get("moveToTrash", False):
1457
- for sHandle in reversed(data.get("finalItems", [])):
1458
- trItem = self._getTreeItem(sHandle)
1459
- if isinstance(trItem, QTreeWidgetItem) and trItem.childCount() == 0:
1460
- self.moveItemToTrash(sHandle, askFirst=False, flush=False)
1461
-
1462
- self._alertTreeChange(mHandle, flush=True)
1463
- self.projView.wordCountsChanged.emit()
1464
-
1465
- else:
1466
- logger.info("Action cancelled by user")
1467
- return False
1468
-
1469
- return True
1470
-
1471
- def _splitDocument(self, tHandle: str) -> bool:
1472
- """Split a document into multiple documents."""
1473
- logger.info("Request to split items with handle '%s'", tHandle)
1474
-
1475
- tItem = SHARED.project.tree[tHandle]
1476
- if tItem is None:
1477
- return False
1478
-
1479
- if not tItem.isFileType() or tItem.itemParent is None:
1480
- logger.error("Only valid document items can be split")
1481
- return False
1482
-
1483
- data, text, status = GuiDocSplit.getData(SHARED.mainGui, tHandle)
1484
- if status:
1485
- headerList = data.get("headerList", [])
1486
- intoFolder = data.get("intoFolder", False)
1487
- docHierarchy = data.get("docHierarchy", False)
1488
-
1489
- docSplit = DocSplitter(SHARED.project, tHandle)
1490
- if intoFolder:
1491
- fHandle = docSplit.newParentFolder(tItem.itemParent, tItem.itemName)
1492
- self.revealNewTreeItem(fHandle, nHandle=tHandle)
1493
- self._alertTreeChange(fHandle, flush=False)
1494
993
  else:
1495
- docSplit.setParentItem(tItem.itemParent)
1496
-
1497
- docSplit.splitDocument(headerList, text)
1498
- for writeOk, dHandle, nHandle in docSplit.writeDocuments(docHierarchy):
1499
- SHARED.project.index.reIndexHandle(dHandle)
1500
- self.revealNewTreeItem(dHandle, nHandle=nHandle, wordCount=True)
1501
- self._alertTreeChange(dHandle, flush=False)
1502
- if not writeOk:
1503
- SHARED.error(
1504
- self.tr("Could not write document content."),
1505
- info=docSplit.getError()
1506
- )
1507
-
1508
- if data.get("moveToTrash", False):
1509
- self.moveItemToTrash(tHandle, askFirst=False, flush=True)
1510
-
1511
- self.saveTreeOrder()
1512
-
1513
- else:
1514
- logger.info("Action cancelled by user")
1515
- return False
1516
-
1517
- return True
1518
-
1519
- def _duplicateFromHandle(self, tHandle: str) -> bool:
1520
- """Duplicate the item hierarchy from a given item."""
1521
- itemTree = self.getTreeFromHandle(tHandle)
1522
- nItems = len(itemTree)
1523
- if nItems == 0:
1524
- return False
1525
- elif nItems == 1:
1526
- question = self.tr("Do you want to duplicate this document?")
1527
- else:
1528
- question = self.tr("Do you want to duplicate this item and all child items?")
1529
-
1530
- if not SHARED.question(question):
1531
- return False
1532
-
1533
- docDup = DocDuplicator(SHARED.project)
1534
- dupCount = 0
1535
- for dHandle, nHandle in docDup.duplicate(itemTree):
1536
- SHARED.project.index.reIndexHandle(dHandle)
1537
- self.revealNewTreeItem(dHandle, nHandle=nHandle, wordCount=True)
1538
- self._alertTreeChange(dHandle, flush=False)
1539
- dupCount += 1
1540
-
1541
- if dupCount != nItems:
1542
- SHARED.warn(self.tr("Could not duplicate all items."))
1543
-
1544
- self.saveTreeOrder()
1545
-
1546
- return True
1547
-
1548
- def _scanChildren(self, itemList: list, tItem: QTreeWidgetItem, tIndex: int) -> list[str]:
1549
- """This is a recursive function returning all items in a tree
1550
- starting at a given QTreeWidgetItem.
1551
- """
1552
- tHandle = tItem.data(self.C_DATA, self.D_HANDLE)
1553
- cCount = tItem.childCount()
1554
-
1555
- # Update tree-related meta data
1556
- nwItem = SHARED.project.tree[tHandle]
1557
- if nwItem is not None:
1558
- nwItem.setExpanded(tItem.isExpanded() and cCount > 0)
1559
- nwItem.setOrder(tIndex)
1560
-
1561
- itemList.append(tHandle)
1562
- for i in range(cCount):
1563
- self._scanChildren(itemList, tItem.child(i), i)
1564
-
1565
- return itemList
1566
-
1567
- def _addTreeItem(self, nwItem: NWItem | None,
1568
- nHandle: str | None = None) -> QTreeWidgetItem | None:
1569
- """Create a QTreeWidgetItem from an NWItem and add it to the
1570
- project tree. Returns the widget if the item is valid, otherwise
1571
- a None is returned.
1572
- """
1573
- if not nwItem:
1574
- logger.error("Invalid item cannot be added to project tree")
1575
- return None
1576
-
1577
- tHandle = nwItem.itemHandle
1578
- pHandle = nwItem.itemParent
1579
- newItem = QTreeWidgetItem()
1580
-
1581
- newItem.setText(self.C_NAME, "")
1582
- newItem.setText(self.C_COUNT, "0")
1583
- newItem.setText(self.C_ACTIVE, "")
1584
- newItem.setText(self.C_STATUS, "")
1585
-
1586
- newItem.setTextAlignment(self.C_NAME, QtAlignLeft)
1587
- newItem.setTextAlignment(self.C_COUNT, QtAlignRight)
1588
- newItem.setTextAlignment(self.C_ACTIVE, QtAlignLeft)
1589
- newItem.setTextAlignment(self.C_STATUS, QtAlignLeft)
1590
-
1591
- newItem.setData(self.C_DATA, self.D_HANDLE, tHandle)
1592
- newItem.setData(self.C_DATA, self.D_WORDS, 0)
1593
-
1594
- if pHandle is None and nwItem.isRootType():
1595
- pItem = self.invisibleRootItem()
1596
- elif pHandle and pHandle in self._treeMap:
1597
- pItem = self._treeMap[pHandle]
1598
- else:
1599
- SHARED.error(self.tr(
1600
- "There is nowhere to add item with name '{0}'."
1601
- ).format(nwItem.itemName))
1602
- return None
1603
-
1604
- byIndex = -1
1605
- if nHandle is not None and nHandle in self._treeMap:
1606
- byIndex = pItem.indexOfChild(self._treeMap[nHandle])
1607
- if byIndex >= 0:
1608
- pItem.insertChild(byIndex + 1, newItem)
1609
- else:
1610
- pItem.addChild(newItem)
994
+ self.setExpanded(index, not self.isExpanded(index))
995
+ return
1611
996
 
1612
- self._treeMap[tHandle] = newItem
1613
- self.propagateCount(tHandle, nwItem.wordCount, countChildren=True)
1614
- self.setTreeItemValues(nwItem)
1615
- newItem.setExpanded(nwItem.isExpanded)
997
+ @pyqtSlot(QModelIndex)
998
+ def _onNodeCollapsed(self, index: QModelIndex) -> None:
999
+ """Capture a node collapse, and pass it to the model."""
1000
+ if node := self._getNode(index):
1001
+ node.setExpanded(False)
1002
+ return
1616
1003
 
1617
- return newItem
1004
+ @pyqtSlot(QModelIndex)
1005
+ def _onNodeExpanded(self, index: QModelIndex) -> None:
1006
+ """Capture a node expand, and pass it to the model."""
1007
+ if node := self._getNode(index):
1008
+ node.setExpanded(True)
1009
+ return
1618
1010
 
1619
- def _addTrashRoot(self) -> QTreeWidgetItem | None:
1620
- """Adds the trash root folder if it doesn't already exist in the
1621
- project tree.
1622
- """
1623
- trashHandle = SHARED.project.trashFolder()
1624
- if trashHandle is None:
1625
- return None
1626
-
1627
- trItem = self._getTreeItem(trashHandle)
1628
- if trItem is None:
1629
- trItem = self._addTreeItem(SHARED.project.tree[trashHandle])
1630
- if trItem is not None:
1631
- trItem.setExpanded(True)
1632
- self._alertTreeChange(trashHandle, flush=True)
1633
-
1634
- return trItem
1635
-
1636
- def _alertTreeChange(self, tHandle: str | None, flush: bool = False) -> None:
1637
- """Update information on tree change state, and emit necessary
1638
- signals. A flush is only needed if an item is moved, created or
1639
- deleted.
1640
- """
1641
- self._timeChanged = time()
1642
- SHARED.project.setProjectChanged(True)
1643
- if flush:
1644
- self.saveTreeOrder()
1011
+ ##
1012
+ # Internal Functions
1013
+ ##
1645
1014
 
1646
- if tHandle is None or tHandle not in SHARED.project.tree:
1647
- return
1015
+ def _clearSelection(self) -> None:
1016
+ """Clear the currently selected items."""
1017
+ self.clearSelection()
1018
+ if model := self.selectionModel():
1019
+ # Selection model can be None (#2173)
1020
+ model.clearCurrentIndex()
1021
+ return
1648
1022
 
1649
- tItem = SHARED.project.tree[tHandle]
1650
- if tItem and tItem.isRootType():
1651
- self.projView.rootFolderChanged.emit(tHandle)
1023
+ def _selectedRows(self) -> list[QModelIndex]:
1024
+ """Return all column 0 indexes."""
1025
+ return [i for i in self.selectedIndexes() if i.column() == 0]
1652
1026
 
1653
- self.projView.treeItemChanged.emit(tHandle)
1027
+ def _getModel(self) -> ProjectModel | None:
1028
+ """Return a project node corresponding to a model index."""
1029
+ if isinstance(model := self.model(), ProjectModel):
1030
+ return model
1031
+ return None
1654
1032
 
1655
- return
1033
+ def _getNode(self, index: QModelIndex) -> ProjectNode | None:
1034
+ """Return a project node corresponding to a model index."""
1035
+ if isinstance(model := self.model(), ProjectModel) and (node := model.node(index)):
1036
+ return node
1037
+ return None
1656
1038
 
1657
1039
 
1658
1040
  class _UpdatableMenu(QMenu):
@@ -1720,18 +1102,22 @@ class _UpdatableMenu(QMenu):
1720
1102
 
1721
1103
  class _TreeContextMenu(QMenu):
1722
1104
 
1723
- def __init__(self, projTree: GuiProjectTree, nwItem: NWItem) -> None:
1724
- super().__init__(parent=projTree)
1725
-
1726
- self.projTree = projTree
1727
- self.projView = projTree.projView
1728
-
1729
- self._item = nwItem
1730
- self._handle = nwItem.itemHandle
1731
- self._items: list[NWItem] = []
1105
+ __slots__ = ("_tree", "_view", "_node", "_item", "_model", "_handle", "_indices", "_children")
1732
1106
 
1107
+ def __init__(
1108
+ self, projTree: GuiProjectTree, model: ProjectModel,
1109
+ node: ProjectNode, indices: list[QModelIndex]
1110
+ ) -> None:
1111
+ super().__init__(parent=projTree)
1112
+ self._tree = projTree
1113
+ self._view = projTree.projView
1114
+ self._node = node
1115
+ self._item = node.item
1116
+ self._model = model
1117
+ self._handle = node.item.itemHandle
1118
+ self._indices = indices
1119
+ self._children = node.childCount() > 0
1733
1120
  logger.debug("Ready: _TreeContextMenu")
1734
-
1735
1121
  return
1736
1122
 
1737
1123
  def __del__(self) -> None: # pragma: no cover
@@ -1745,14 +1131,15 @@ class _TreeContextMenu(QMenu):
1745
1131
  def buildTrashMenu(self) -> None:
1746
1132
  """Build the special menu for the Trash folder."""
1747
1133
  action = self.addAction(self.tr("Empty Trash"))
1748
- action.triggered.connect(self.projTree.emptyTrash)
1134
+ action.triggered.connect(self._tree.emptyTrash)
1135
+ if self._children:
1136
+ self._expandCollapse()
1749
1137
  return
1750
1138
 
1751
- def buildSingleSelectMenu(self, hasChild: bool) -> None:
1139
+ def buildSingleSelectMenu(self) -> None:
1752
1140
  """Build the single-select menu."""
1753
1141
  isFile = self._item.isFileType()
1754
1142
  isFolder = self._item.isFolderType()
1755
- isRoot = self._item.isRootType()
1756
1143
 
1757
1144
  # Document Actions
1758
1145
  if isFile:
@@ -1765,33 +1152,32 @@ class _TreeContextMenu(QMenu):
1765
1152
 
1766
1153
  # Edit Item Settings
1767
1154
  action = self.addAction(self.tr("Rename"))
1768
- action.triggered.connect(lambda: self.projTree.renameTreeItem(self._handle))
1155
+ action.triggered.connect(qtLambda(self._view.renameTreeItem, self._handle))
1769
1156
  if isFile:
1770
1157
  self._itemHeader()
1771
- self._itemActive(False)
1158
+ self._itemActive()
1772
1159
  self._itemStatusImport(False)
1773
1160
 
1774
1161
  # Transform Item
1775
1162
  if isFile or isFolder:
1776
- self._itemTransform(isFile, isFolder, hasChild)
1163
+ self._itemTransform(isFile, isFolder)
1777
1164
  self.addSeparator()
1778
1165
 
1779
1166
  # Process Item
1780
- self._itemProcess(isFile, isFolder, isRoot, hasChild)
1167
+ if self._children:
1168
+ self._expandCollapse()
1169
+ action = self.addAction(self.tr("Duplicate"))
1170
+ action.triggered.connect(qtLambda(self._tree.duplicateFromHandle, self._handle))
1171
+ self._deleteOrTrash()
1781
1172
 
1782
1173
  return
1783
1174
 
1784
- def buildMultiSelectMenu(self, handles: list[str]) -> None:
1175
+ def buildMultiSelectMenu(self) -> None:
1785
1176
  """Build the multi-select menu."""
1786
- self._items = []
1787
- for tHandle in handles:
1788
- if (tItem := SHARED.project.tree[tHandle]):
1789
- self._items.append(tItem)
1790
-
1791
- self._itemActive(True)
1177
+ self._itemActive()
1792
1178
  self._itemStatusImport(True)
1793
1179
  self.addSeparator()
1794
- self._multiMoveToTrash()
1180
+ self._deleteOrTrash()
1795
1181
  return
1796
1182
 
1797
1183
  ##
@@ -1801,23 +1187,25 @@ class _TreeContextMenu(QMenu):
1801
1187
  def _docActions(self) -> None:
1802
1188
  """Add document actions."""
1803
1189
  action = self.addAction(self.tr("Open Document"))
1804
- action.triggered.connect(
1805
- lambda: self.projView.openDocumentRequest.emit(self._handle, nwDocMode.EDIT, "", True)
1806
- )
1190
+ action.triggered.connect(qtLambda(
1191
+ self._view.openDocumentRequest.emit,
1192
+ self._handle, nwDocMode.EDIT, "", True
1193
+ ))
1807
1194
  action = self.addAction(self.tr("View Document"))
1808
- action.triggered.connect(
1809
- lambda: self.projView.openDocumentRequest.emit(self._handle, nwDocMode.VIEW, "", False)
1810
- )
1195
+ action.triggered.connect(qtLambda(
1196
+ self._view.openDocumentRequest.emit,
1197
+ self._handle, nwDocMode.VIEW, "", False
1198
+ ))
1811
1199
  return
1812
1200
 
1813
1201
  def _itemCreation(self) -> None:
1814
1202
  """Add create item actions."""
1815
1203
  menu = self.addMenu(self.tr("Create New ..."))
1816
- menu.addAction(self.projView.projBar.aAddEmpty)
1817
- menu.addAction(self.projView.projBar.aAddChap)
1818
- menu.addAction(self.projView.projBar.aAddScene)
1819
- menu.addAction(self.projView.projBar.aAddNote)
1820
- menu.addAction(self.projView.projBar.aAddFolder)
1204
+ menu.addAction(self._view.projBar.aAddEmpty)
1205
+ menu.addAction(self._view.projBar.aAddChap)
1206
+ menu.addAction(self._view.projBar.aAddScene)
1207
+ menu.addAction(self._view.projBar.aAddNote)
1208
+ menu.addAction(self._view.projBar.aAddFolder)
1821
1209
  return
1822
1210
 
1823
1211
  def _itemHeader(self) -> None:
@@ -1826,18 +1214,18 @@ class _TreeContextMenu(QMenu):
1826
1214
  if hItem := SHARED.project.index.getItemHeading(self._handle, "T0001"):
1827
1215
  action = self.addAction(self.tr("Rename to Heading"))
1828
1216
  action.triggered.connect(
1829
- lambda: self.projTree.renameTreeItem(self._handle, hItem.title)
1217
+ qtLambda(self._view.renameTreeItem, self._handle, hItem.title)
1830
1218
  )
1831
1219
  return
1832
1220
 
1833
- def _itemActive(self, multi: bool) -> None:
1221
+ def _itemActive(self) -> None:
1834
1222
  """Add Active/Inactive actions."""
1835
- if multi:
1223
+ if len(self._indices) > 1:
1836
1224
  mSub = self.addMenu(self.tr("Set Active to ..."))
1837
- aOne = mSub.addAction(SHARED.theme.getIcon("checked"), self.projTree.trActive)
1838
- aOne.triggered.connect(lambda: self._iterItemActive(True))
1839
- aTwo = mSub.addAction(SHARED.theme.getIcon("unchecked"), self.projTree.trInactive)
1840
- aTwo.triggered.connect(lambda: self._iterItemActive(False))
1225
+ aOne = mSub.addAction(SHARED.theme.getIcon("checked"), self._tree.trActive)
1226
+ aOne.triggered.connect(qtLambda(self._iterItemActive, True))
1227
+ aTwo = mSub.addAction(SHARED.theme.getIcon("unchecked"), self._tree.trInactive)
1228
+ aTwo.triggered.connect(qtLambda(self._iterItemActive, False))
1841
1229
  else:
1842
1230
  action = self.addAction(self.tr("Toggle Active"))
1843
1231
  action.triggered.connect(self._toggleItemActive)
@@ -1848,46 +1236,45 @@ class _TreeContextMenu(QMenu):
1848
1236
  if self._item.isNovelLike():
1849
1237
  menu = self.addMenu(self.tr("Set Status to ..."))
1850
1238
  current = self._item.itemStatus
1851
- for n, (key, entry) in enumerate(SHARED.project.data.itemStatus.iterItems()):
1239
+ for key, entry in SHARED.project.data.itemStatus.iterItems():
1852
1240
  name = entry.name
1853
1241
  if not multi and current == key:
1854
1242
  name += f" ({nwUnicode.U_CHECK})"
1855
1243
  action = menu.addAction(entry.icon, name)
1856
1244
  if multi:
1857
- action.triggered.connect(lambda n, key=key: self._iterSetItemStatus(key))
1245
+ action.triggered.connect(qtLambda(self._iterSetItemStatus, key))
1858
1246
  else:
1859
- action.triggered.connect(lambda n, key=key: self._changeItemStatus(key))
1247
+ action.triggered.connect(qtLambda(self._changeItemStatus, key))
1860
1248
  menu.addSeparator()
1861
1249
  action = menu.addAction(self.tr("Manage Labels ..."))
1862
- action.triggered.connect(
1863
- lambda: self.projView.projectSettingsRequest.emit(GuiProjectSettings.PAGE_STATUS)
1864
- )
1250
+ action.triggered.connect(qtLambda(
1251
+ self._view.projectSettingsRequest.emit,
1252
+ GuiProjectSettings.PAGE_STATUS
1253
+ ))
1865
1254
  else:
1866
1255
  menu = self.addMenu(self.tr("Set Importance to ..."))
1867
1256
  current = self._item.itemImport
1868
- for n, (key, entry) in enumerate(SHARED.project.data.itemImport.iterItems()):
1257
+ for key, entry in SHARED.project.data.itemImport.iterItems():
1869
1258
  name = entry.name
1870
1259
  if not multi and current == key:
1871
1260
  name += f" ({nwUnicode.U_CHECK})"
1872
1261
  action = menu.addAction(entry.icon, name)
1873
1262
  if multi:
1874
- action.triggered.connect(lambda n, key=key: self._iterSetItemImport(key))
1263
+ action.triggered.connect(qtLambda(self._iterSetItemImport, key))
1875
1264
  else:
1876
- action.triggered.connect(lambda n, key=key: self._changeItemImport(key))
1265
+ action.triggered.connect(qtLambda(self._changeItemImport, key))
1877
1266
  menu.addSeparator()
1878
1267
  action = menu.addAction(self.tr("Manage Labels ..."))
1879
- action.triggered.connect(
1880
- lambda: self.projView.projectSettingsRequest.emit(GuiProjectSettings.PAGE_IMPORT)
1881
- )
1268
+ action.triggered.connect(qtLambda(
1269
+ self._view.projectSettingsRequest.emit,
1270
+ GuiProjectSettings.PAGE_IMPORT
1271
+ ))
1882
1272
  return
1883
1273
 
1884
- def _itemTransform(self, isFile: bool, isFolder: bool, hasChild: bool) -> None:
1274
+ def _itemTransform(self, isFile: bool, isFolder: bool) -> None:
1885
1275
  """Add actions for the Transform menu."""
1886
1276
  menu = self.addMenu(self.tr("Transform ..."))
1887
1277
 
1888
- tree = self.projTree
1889
- tHandle = self._handle
1890
-
1891
1278
  trDoc = trConst(nwLabels.LAYOUT_NAME[nwItemLayout.DOCUMENT])
1892
1279
  trNote = trConst(nwLabels.LAYOUT_NAME[nwItemLayout.NOTE])
1893
1280
  loDoc = nwItemLayout.DOCUMENT
@@ -1897,159 +1284,126 @@ class _TreeContextMenu(QMenu):
1897
1284
 
1898
1285
  if isNoteFile and self._item.documentAllowed():
1899
1286
  action = menu.addAction(self.tr("Convert to {0}").format(trDoc))
1900
- action.triggered.connect(lambda: self._changeItemLayout(loDoc))
1287
+ action.triggered.connect(qtLambda(self._changeItemLayout, loDoc))
1901
1288
 
1902
1289
  if isDocFile:
1903
1290
  action = menu.addAction(self.tr("Convert to {0}").format(trNote))
1904
- action.triggered.connect(lambda: self._changeItemLayout(loNote))
1291
+ action.triggered.connect(qtLambda(self._changeItemLayout, loNote))
1905
1292
 
1906
1293
  if isFolder and self._item.documentAllowed():
1907
1294
  action = menu.addAction(self.tr("Convert to {0}").format(trDoc))
1908
- action.triggered.connect(lambda: self._covertFolderToFile(loDoc))
1295
+ action.triggered.connect(qtLambda(self._convertFolderToFile, loDoc))
1909
1296
 
1910
1297
  if isFolder:
1911
1298
  action = menu.addAction(self.tr("Convert to {0}").format(trNote))
1912
- action.triggered.connect(lambda: self._covertFolderToFile(loNote))
1299
+ action.triggered.connect(qtLambda(self._convertFolderToFile, loNote))
1913
1300
 
1914
- if hasChild and isFile:
1301
+ if self._children and isFile:
1915
1302
  action = menu.addAction(self.tr("Merge Child Items into Self"))
1916
- action.triggered.connect(lambda: tree._mergeDocuments(tHandle, False))
1303
+ action.triggered.connect(qtLambda(self._tree.mergeDocuments, self._handle, False))
1917
1304
  action = menu.addAction(self.tr("Merge Child Items into New"))
1918
- action.triggered.connect(lambda: tree._mergeDocuments(tHandle, True))
1305
+ action.triggered.connect(qtLambda(self._tree.mergeDocuments, self._handle, True))
1919
1306
 
1920
- if hasChild and isFolder:
1307
+ if self._children and isFolder:
1921
1308
  action = menu.addAction(self.tr("Merge Documents in Folder"))
1922
- action.triggered.connect(lambda: tree._mergeDocuments(tHandle, True))
1309
+ action.triggered.connect(qtLambda(self._tree.mergeDocuments, self._handle, True))
1923
1310
 
1924
1311
  if isFile:
1925
1312
  action = menu.addAction(self.tr("Split Document by Headings"))
1926
- action.triggered.connect(lambda: tree._splitDocument(tHandle))
1313
+ action.triggered.connect(qtLambda(self._tree.splitDocument, self._handle))
1927
1314
 
1928
1315
  return
1929
1316
 
1930
- def _itemProcess(self, isFile: bool, isFolder: bool, isRoot: bool, hasChild: bool) -> None:
1931
- """Add actions for item processing."""
1932
- tree = self.projTree
1933
- tHandle = self._handle
1934
- if hasChild:
1935
- action = self.addAction(self.tr("Expand All"))
1936
- action.triggered.connect(lambda: tree.setExpandedFromHandle(tHandle, True))
1937
- action = self.addAction(self.tr("Collapse All"))
1938
- action.triggered.connect(lambda: tree.setExpandedFromHandle(tHandle, False))
1939
-
1940
- action = self.addAction(self.tr("Duplicate"))
1941
- action.triggered.connect(lambda: tree._duplicateFromHandle(tHandle))
1942
-
1943
- if self._item.itemClass == nwItemClass.TRASH or isRoot or (isFolder and not hasChild):
1944
- action = self.addAction(self.tr("Delete Permanently"))
1945
- action.triggered.connect(lambda: tree.permDeleteItem(tHandle))
1946
- else:
1947
- action = self.addAction(self.tr("Move to Trash"))
1948
- action.triggered.connect(lambda: tree.moveItemToTrash(tHandle))
1949
-
1317
+ def _expandCollapse(self) -> None:
1318
+ """Add actions for expand and collapse."""
1319
+ action = self.addAction(self.tr("Expand All"))
1320
+ action.triggered.connect(qtLambda(self._tree.expandFromIndex, self._indices[0]))
1321
+ action = self.addAction(self.tr("Collapse All"))
1322
+ action.triggered.connect(qtLambda(self._tree.collapseFromIndex, self._indices[0]))
1950
1323
  return
1951
1324
 
1952
- def _multiMoveToTrash(self) -> None:
1325
+ def _deleteOrTrash(self) -> None:
1953
1326
  """Add move to Trash action."""
1954
- areTrash = [i.itemClass == nwItemClass.TRASH for i in self._items]
1955
- if all(areTrash):
1956
- action = self.addAction(self.tr("Delete Permanently"))
1957
- action.triggered.connect(self._iterPermDelete)
1958
- elif not any(areTrash):
1959
- action = self.addAction(self.tr("Move to Trash"))
1960
- action.triggered.connect(self._iterMoveToTrash)
1327
+ if (
1328
+ self._model.trashSelection(self._indices)
1329
+ or len(self._indices) == 1 and self._item.isRootType()
1330
+ ):
1331
+ text = self.tr("Delete Permanently")
1332
+ else:
1333
+ text = self.tr("Move to Trash")
1334
+ action = self.addAction(text)
1335
+ action.triggered.connect(self._tree.processDeleteRequest)
1961
1336
  return
1962
1337
 
1963
1338
  ##
1964
1339
  # Private Slots
1965
1340
  ##
1966
1341
 
1967
- @pyqtSlot()
1968
- def _iterMoveToTrash(self) -> None:
1969
- """Iterate through files and move them to Trash."""
1970
- if SHARED.question(self.tr("Move {0} items to Trash?").format(len(self._items))):
1971
- for tItem in self._items:
1972
- if tItem.isFileType() and tItem.itemClass != nwItemClass.TRASH:
1973
- self.projTree.moveItemToTrash(tItem.itemHandle, askFirst=False, flush=False)
1974
- self.projTree.saveTreeOrder()
1975
- return
1976
-
1977
- @pyqtSlot()
1978
- def _iterPermDelete(self) -> None:
1979
- """Iterate through files and delete them."""
1980
- if SHARED.question(self.projTree.trPermDelete.format(len(self._items))):
1981
- for tItem in self._items:
1982
- if tItem.isFileType() and tItem.itemClass == nwItemClass.TRASH:
1983
- self.projTree.permDeleteItem(tItem.itemHandle, askFirst=False, flush=False)
1984
- self.projTree.saveTreeOrder()
1985
- return
1986
-
1987
1342
  @pyqtSlot()
1988
1343
  def _toggleItemActive(self) -> None:
1989
1344
  """Toggle the active status of an item."""
1990
- self._item.setActive(not self._item.isActive)
1991
- self.projTree.setTreeItemValues(self._item)
1992
- self.projTree._alertTreeChange(self._handle, flush=False)
1345
+ if self._item.isFileType():
1346
+ self._item.setActive(not self._item.isActive)
1347
+ self._item.notifyToRefresh()
1993
1348
  return
1994
1349
 
1995
1350
  ##
1996
1351
  # Internal Functions
1997
1352
  ##
1998
1353
 
1999
- def _iterItemActive(self, isActive: bool) -> None:
1354
+ def _iterItemActive(self, state: bool) -> None:
2000
1355
  """Set the active status of multiple items."""
2001
- for tItem in self._items:
2002
- if tItem and tItem.isFileType():
2003
- tItem.setActive(isActive)
2004
- self.projTree.setTreeItemValues(tItem)
2005
- self.projTree._alertTreeChange(tItem.itemHandle, flush=False)
1356
+ refresh = []
1357
+ for node in self._model.nodes(self._indices):
1358
+ if node.item.isFileType():
1359
+ node.item.setActive(state)
1360
+ refresh.append(node.item.itemHandle)
1361
+ SHARED.project.tree.refreshItems(refresh)
2006
1362
  return
2007
1363
 
2008
1364
  def _changeItemStatus(self, key: str) -> None:
2009
1365
  """Set a new status value of an item."""
2010
1366
  self._item.setStatus(key)
2011
- self.projTree.setTreeItemValues(self._item)
2012
- self.projTree._alertTreeChange(self._handle, flush=False)
1367
+ self._item.notifyToRefresh()
2013
1368
  return
2014
1369
 
2015
1370
  def _iterSetItemStatus(self, key: str) -> None:
2016
1371
  """Change the status value for multiple items."""
2017
- for tItem in self._items:
2018
- if tItem and tItem.isNovelLike():
2019
- tItem.setStatus(key)
2020
- self.projTree.setTreeItemValues(tItem)
2021
- self.projTree._alertTreeChange(tItem.itemHandle, flush=False)
1372
+ refresh = []
1373
+ for node in self._model.nodes(self._indices):
1374
+ if node.item.isNovelLike():
1375
+ node.item.setStatus(key)
1376
+ refresh.append(node.item.itemHandle)
1377
+ SHARED.project.tree.refreshItems(refresh)
2022
1378
  return
2023
1379
 
2024
1380
  def _changeItemImport(self, key: str) -> None:
2025
1381
  """Set a new importance value of an item."""
2026
1382
  self._item.setImport(key)
2027
- self.projTree.setTreeItemValues(self._item)
2028
- self.projTree._alertTreeChange(self._handle, flush=False)
1383
+ self._item.notifyToRefresh()
2029
1384
  return
2030
1385
 
2031
1386
  def _iterSetItemImport(self, key: str) -> None:
2032
1387
  """Change the status value for multiple items."""
2033
- for tItem in self._items:
2034
- if tItem and not tItem.isNovelLike():
2035
- tItem.setImport(key)
2036
- self.projTree.setTreeItemValues(tItem)
2037
- self.projTree._alertTreeChange(tItem.itemHandle, flush=False)
1388
+ refresh = []
1389
+ for node in self._model.nodes(self._indices):
1390
+ if not node.item.isNovelLike():
1391
+ node.item.setImport(key)
1392
+ refresh.append(node.item.itemHandle)
1393
+ SHARED.project.tree.refreshItems(refresh)
2038
1394
  return
2039
1395
 
2040
1396
  def _changeItemLayout(self, itemLayout: nwItemLayout) -> None:
2041
1397
  """Set a new item layout value of an item."""
2042
1398
  if itemLayout == nwItemLayout.DOCUMENT and self._item.documentAllowed():
2043
1399
  self._item.setLayout(nwItemLayout.DOCUMENT)
2044
- self.projTree.setTreeItemValues(self._item)
2045
- self.projTree._alertTreeChange(self._handle, flush=False)
1400
+ self._item.notifyToRefresh()
2046
1401
  elif itemLayout == nwItemLayout.NOTE:
2047
1402
  self._item.setLayout(nwItemLayout.NOTE)
2048
- self.projTree.setTreeItemValues(self._item)
2049
- self.projTree._alertTreeChange(self._handle, flush=False)
1403
+ self._item.notifyToRefresh()
2050
1404
  return
2051
1405
 
2052
- def _covertFolderToFile(self, itemLayout: nwItemLayout) -> None:
1406
+ def _convertFolderToFile(self, itemLayout: nwItemLayout) -> None:
2053
1407
  """Convert a folder to a note or document."""
2054
1408
  if self._item.isFolderType():
2055
1409
  msgYes = SHARED.question(self.tr(
@@ -2059,13 +1413,11 @@ class _TreeContextMenu(QMenu):
2059
1413
  if msgYes and itemLayout == nwItemLayout.DOCUMENT and self._item.documentAllowed():
2060
1414
  self._item.setType(nwItemType.FILE)
2061
1415
  self._item.setLayout(nwItemLayout.DOCUMENT)
2062
- self.projTree.setTreeItemValues(self._item)
2063
- self.projTree._alertTreeChange(self._handle, flush=False)
1416
+ self._item.notifyToRefresh()
2064
1417
  elif msgYes and itemLayout == nwItemLayout.NOTE:
2065
1418
  self._item.setType(nwItemType.FILE)
2066
1419
  self._item.setLayout(nwItemLayout.NOTE)
2067
- self.projTree.setTreeItemValues(self._item)
2068
- self.projTree._alertTreeChange(self._handle, flush=False)
1420
+ self._item.notifyToRefresh()
2069
1421
  else:
2070
1422
  logger.info("Folder conversion cancelled")
2071
1423
  return