novelWriter 2.6b1__py3-none-any.whl → 2.6b2__py3-none-any.whl

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