novelWriter 2.2b1__py3-none-any.whl → 2.2rc1__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 (62) hide show
  1. {novelWriter-2.2b1.dist-info → novelWriter-2.2rc1.dist-info}/METADATA +3 -3
  2. {novelWriter-2.2b1.dist-info → novelWriter-2.2rc1.dist-info}/RECORD +60 -48
  3. novelwriter/__init__.py +3 -3
  4. novelwriter/assets/i18n/project_en_GB.json +1 -0
  5. novelwriter/assets/icons/novelwriter.ico +0 -0
  6. novelwriter/assets/icons/typicons_dark/icons.conf +8 -1
  7. novelwriter/assets/icons/typicons_dark/nw_deco-h2-narrow.svg +4 -0
  8. novelwriter/assets/icons/typicons_dark/nw_deco-h3-narrow.svg +4 -0
  9. novelwriter/assets/icons/typicons_dark/nw_deco-h4-narrow.svg +4 -0
  10. novelwriter/assets/icons/typicons_dark/nw_deco-note.svg +4 -0
  11. novelwriter/assets/icons/typicons_dark/nw_panel.svg +4 -0
  12. novelwriter/assets/icons/typicons_dark/typ_eye.svg +4 -0
  13. novelwriter/assets/icons/typicons_light/icons.conf +8 -1
  14. novelwriter/assets/icons/typicons_light/nw_deco-h2-narrow.svg +4 -0
  15. novelwriter/assets/icons/typicons_light/nw_deco-h3-narrow.svg +4 -0
  16. novelwriter/assets/icons/typicons_light/nw_deco-h4-narrow.svg +4 -0
  17. novelwriter/assets/icons/typicons_light/nw_deco-note.svg +4 -0
  18. novelwriter/assets/icons/typicons_light/nw_panel.svg +4 -0
  19. novelwriter/assets/icons/typicons_light/typ_eye.svg +4 -0
  20. novelwriter/assets/icons/x-novelwriter-project.ico +0 -0
  21. novelwriter/assets/manual.pdf +0 -0
  22. novelwriter/assets/sample.zip +0 -0
  23. novelwriter/assets/text/release_notes.htm +4 -4
  24. novelwriter/common.py +22 -1
  25. novelwriter/config.py +12 -27
  26. novelwriter/constants.py +20 -3
  27. novelwriter/core/buildsettings.py +1 -1
  28. novelwriter/core/coretools.py +6 -1
  29. novelwriter/core/index.py +100 -34
  30. novelwriter/core/options.py +3 -0
  31. novelwriter/core/project.py +2 -2
  32. novelwriter/core/projectdata.py +1 -1
  33. novelwriter/core/tohtml.py +9 -3
  34. novelwriter/core/tokenizer.py +27 -20
  35. novelwriter/core/tomd.py +4 -0
  36. novelwriter/core/toodt.py +11 -4
  37. novelwriter/dialogs/preferences.py +80 -82
  38. novelwriter/dialogs/updates.py +25 -14
  39. novelwriter/enum.py +14 -4
  40. novelwriter/gui/doceditor.py +282 -177
  41. novelwriter/gui/dochighlight.py +7 -9
  42. novelwriter/gui/docviewer.py +142 -319
  43. novelwriter/gui/docviewerpanel.py +457 -0
  44. novelwriter/gui/editordocument.py +1 -1
  45. novelwriter/gui/mainmenu.py +16 -7
  46. novelwriter/gui/outline.py +10 -6
  47. novelwriter/gui/projtree.py +461 -376
  48. novelwriter/gui/sidebar.py +3 -3
  49. novelwriter/gui/statusbar.py +1 -1
  50. novelwriter/gui/theme.py +21 -2
  51. novelwriter/guimain.py +86 -32
  52. novelwriter/shared.py +23 -1
  53. novelwriter/tools/dictionaries.py +268 -0
  54. novelwriter/tools/manusbuild.py +17 -6
  55. novelwriter/tools/manuscript.py +1 -1
  56. novelwriter/tools/writingstats.py +1 -1
  57. novelwriter/assets/icons/typicons_dark/typ_at.svg +0 -4
  58. novelwriter/assets/icons/typicons_light/typ_at.svg +0 -4
  59. {novelWriter-2.2b1.dist-info → novelWriter-2.2rc1.dist-info}/LICENSE.md +0 -0
  60. {novelWriter-2.2b1.dist-info → novelWriter-2.2rc1.dist-info}/WHEEL +0 -0
  61. {novelWriter-2.2b1.dist-info → novelWriter-2.2rc1.dist-info}/entry_points.txt +0 -0
  62. {novelWriter-2.2b1.dist-info → novelWriter-2.2rc1.dist-info}/top_level.txt +0 -0
@@ -31,7 +31,7 @@ from enum import Enum
31
31
  from time import time
32
32
  from typing import TYPE_CHECKING
33
33
 
34
- from PyQt5.QtGui import QDragMoveEvent, QDropEvent, QMouseEvent, QPalette
34
+ from PyQt5.QtGui import QDragEnterEvent, QDragMoveEvent, QDropEvent, QMouseEvent, QPalette
35
35
  from PyQt5.QtCore import QPoint, QTimer, Qt, QSize, pyqtSignal, pyqtSlot
36
36
  from PyQt5.QtWidgets import (
37
37
  QAbstractItemView, QDialog, QFrame, QHBoxLayout, QHeaderView, QLabel,
@@ -98,42 +98,37 @@ class GuiProjectView(QWidget):
98
98
  # Keyboard Shortcuts
99
99
  self.keyMoveUp = QShortcut(self.projTree)
100
100
  self.keyMoveUp.setKey("Ctrl+Up")
101
- self.keyMoveUp.setContext(Qt.WidgetShortcut)
101
+ self.keyMoveUp.setContext(Qt.ShortcutContext.WidgetShortcut)
102
102
  self.keyMoveUp.activated.connect(lambda: self.projTree.moveTreeItem(-1))
103
103
 
104
104
  self.keyMoveDn = QShortcut(self.projTree)
105
105
  self.keyMoveDn.setKey("Ctrl+Down")
106
- self.keyMoveDn.setContext(Qt.WidgetShortcut)
106
+ self.keyMoveDn.setContext(Qt.ShortcutContext.WidgetShortcut)
107
107
  self.keyMoveDn.activated.connect(lambda: self.projTree.moveTreeItem(1))
108
108
 
109
109
  self.keyGoPrev = QShortcut(self.projTree)
110
110
  self.keyGoPrev.setKey("Alt+Up")
111
- self.keyGoPrev.setContext(Qt.WidgetShortcut)
111
+ self.keyGoPrev.setContext(Qt.ShortcutContext.WidgetShortcut)
112
112
  self.keyGoPrev.activated.connect(lambda: self.projTree.moveToNextItem(-1))
113
113
 
114
114
  self.keyGoNext = QShortcut(self.projTree)
115
115
  self.keyGoNext.setKey("Alt+Down")
116
- self.keyGoNext.setContext(Qt.WidgetShortcut)
116
+ self.keyGoNext.setContext(Qt.ShortcutContext.WidgetShortcut)
117
117
  self.keyGoNext.activated.connect(lambda: self.projTree.moveToNextItem(1))
118
118
 
119
119
  self.keyGoUp = QShortcut(self.projTree)
120
120
  self.keyGoUp.setKey("Alt+Left")
121
- self.keyGoUp.setContext(Qt.WidgetShortcut)
121
+ self.keyGoUp.setContext(Qt.ShortcutContext.WidgetShortcut)
122
122
  self.keyGoUp.activated.connect(lambda: self.projTree.moveToLevel(-1))
123
123
 
124
124
  self.keyGoDown = QShortcut(self.projTree)
125
125
  self.keyGoDown.setKey("Alt+Right")
126
- self.keyGoDown.setContext(Qt.WidgetShortcut)
126
+ self.keyGoDown.setContext(Qt.ShortcutContext.WidgetShortcut)
127
127
  self.keyGoDown.activated.connect(lambda: self.projTree.moveToLevel(1))
128
128
 
129
- self.keyUndoMv = QShortcut(self.projTree)
130
- self.keyUndoMv.setKey("Ctrl+Shift+Z")
131
- self.keyUndoMv.setContext(Qt.WidgetShortcut)
132
- self.keyUndoMv.activated.connect(lambda: self.projTree.undoLastMove())
133
-
134
129
  self.keyContext = QShortcut(self.projTree)
135
130
  self.keyContext.setKey("Ctrl+.")
136
- self.keyContext.setContext(Qt.WidgetShortcut)
131
+ self.keyContext.setContext(Qt.ShortcutContext.WidgetShortcut)
137
132
  self.keyContext.activated.connect(lambda: self.projTree.openContextOnSelected())
138
133
 
139
134
  # Signals
@@ -142,10 +137,7 @@ class GuiProjectView(QWidget):
142
137
  # Function Mappings
143
138
  self.emptyTrash = self.projTree.emptyTrash
144
139
  self.requestDeleteItem = self.projTree.requestDeleteItem
145
- self.setTreeItemValues = self.projTree.setTreeItemValues
146
- self.propagateCount = self.projTree.propagateCount
147
140
  self.getSelectedHandle = self.projTree.getSelectedHandle
148
- self.setSelectedHandle = self.projTree.setSelectedHandle
149
141
  self.changedSince = self.projTree.changedSince
150
142
  self.createNewNote = self.projTree.createNewNote
151
143
 
@@ -198,17 +190,32 @@ class GuiProjectView(QWidget):
198
190
  """Check if the project tree has focus."""
199
191
  return self.projTree.hasFocus()
200
192
 
201
- def renameTreeItem(self, tHandle: str | None = None) -> bool:
193
+ ##
194
+ # Public Slots
195
+ ##
196
+
197
+ @pyqtSlot(str, str)
198
+ def renameTreeItem(self, tHandle: str | None = None, name: str = "") -> None:
202
199
  """External request to rename an item or the currently selected
203
200
  item. This is triggered by the global menu or keyboard shortcut.
204
201
  """
205
202
  if tHandle is None:
206
203
  tHandle = self.projTree.getSelectedHandle()
207
- return self.projTree.renameTreeItem(tHandle) if tHandle else False
204
+ if tHandle:
205
+ self.projTree.renameTreeItem(tHandle, name=name)
206
+ return
208
207
 
209
- ##
210
- # Public Slots
211
- ##
208
+ @pyqtSlot(str, bool)
209
+ def setSelectedHandle(self, tHandle: str, doScroll: bool = False) -> None:
210
+ """Select an item and optionally scroll it into view."""
211
+ self.projTree.setSelectedHandle(tHandle, doScroll=doScroll)
212
+ return
213
+
214
+ @pyqtSlot(str)
215
+ def updateItemValues(self, tHandle: str) -> None:
216
+ """Update tree item"""
217
+ self.projTree.setTreeItemValues(tHandle)
218
+ return
212
219
 
213
220
  @pyqtSlot(str, int, int, int)
214
221
  def updateCounts(self, tHandle: str, cCount: int, wCount: int, pCount: int) -> None:
@@ -256,7 +263,7 @@ class GuiProjectToolBar(QWidget):
256
263
  self.tbQuick.setShortcut("Ctrl+L")
257
264
  self.tbQuick.setIconSize(QSize(iPx, iPx))
258
265
  self.tbQuick.setMenu(self.mQuick)
259
- self.tbQuick.setPopupMode(QToolButton.InstantPopup)
266
+ self.tbQuick.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
260
267
 
261
268
  # Move Buttons
262
269
  self.tbMoveU = QToolButton(self)
@@ -305,7 +312,7 @@ class GuiProjectToolBar(QWidget):
305
312
  self.tbAdd.setShortcut("Ctrl+N")
306
313
  self.tbAdd.setIconSize(QSize(iPx, iPx))
307
314
  self.tbAdd.setMenu(self.mAdd)
308
- self.tbAdd.setPopupMode(QToolButton.InstantPopup)
315
+ self.tbAdd.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
309
316
 
310
317
  # More Options Menu
311
318
  self.mMore = QMenu(self)
@@ -316,9 +323,6 @@ class GuiProjectToolBar(QWidget):
316
323
  self.aCollapse = self.mMore.addAction(self.tr("Collapse All"))
317
324
  self.aCollapse.triggered.connect(lambda: self.projTree.setExpandedFromHandle(None, False))
318
325
 
319
- self.aMoreUndo = self.mMore.addAction(self.tr("Undo Move"))
320
- self.aMoreUndo.triggered.connect(lambda: self.projTree.undoLastMove())
321
-
322
326
  self.aEmptyTrash = self.mMore.addAction(self.tr("Empty Trash"))
323
327
  self.aEmptyTrash.triggered.connect(lambda: self.projTree.emptyTrash())
324
328
 
@@ -326,7 +330,7 @@ class GuiProjectToolBar(QWidget):
326
330
  self.tbMore.setToolTip(self.tr("More Options"))
327
331
  self.tbMore.setIconSize(QSize(iPx, iPx))
328
332
  self.tbMore.setMenu(self.mMore)
329
- self.tbMore.setPopupMode(QToolButton.InstantPopup)
333
+ self.tbMore.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
330
334
 
331
335
  # Assemble
332
336
  self.outerBox = QHBoxLayout()
@@ -353,7 +357,7 @@ class GuiProjectToolBar(QWidget):
353
357
  def updateTheme(self) -> None:
354
358
  """Update theme elements."""
355
359
  qPalette = self.palette()
356
- qPalette.setBrush(QPalette.Window, qPalette.base())
360
+ qPalette.setBrush(QPalette.ColorRole.Window, qPalette.base())
357
361
  self.setPalette(qPalette)
358
362
 
359
363
  fadeCol = qPalette.text().color()
@@ -431,7 +435,7 @@ class GuiProjectToolBar(QWidget):
431
435
  return
432
436
 
433
437
  ##
434
- # Slots
438
+ # Private Slots
435
439
  ##
436
440
 
437
441
  @pyqtSlot(str)
@@ -471,14 +475,14 @@ class GuiProjectTree(QTreeWidget):
471
475
 
472
476
  # Internal Variables
473
477
  self._treeMap = {}
474
- self._lastMove = {}
475
478
  self._timeChanged = 0.0
479
+ self._popAlert = None
476
480
 
477
481
  # Build GUI
478
482
  # =========
479
483
 
480
484
  # Context Menu
481
- self.setContextMenuPolicy(Qt.CustomContextMenu)
485
+ self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
482
486
  self.customContextMenuRequested.connect(self._openContextMenu)
483
487
 
484
488
  # Tree Settings
@@ -486,7 +490,7 @@ class GuiProjectTree(QTreeWidget):
486
490
  cMg = CONFIG.pxInt(6)
487
491
 
488
492
  self.setIconSize(QSize(iPx, iPx))
489
- self.setFrameStyle(QFrame.NoFrame)
493
+ self.setFrameStyle(QFrame.Shape.NoFrame)
490
494
  self.setUniformRowHeights(True)
491
495
  self.setAllColumnsShowFocus(True)
492
496
  self.setExpandsOnDoubleClick(False)
@@ -499,19 +503,18 @@ class GuiProjectTree(QTreeWidget):
499
503
  treeHeader = self.header()
500
504
  treeHeader.setStretchLastSection(False)
501
505
  treeHeader.setMinimumSectionSize(iPx + cMg)
502
- treeHeader.setSectionResizeMode(self.C_NAME, QHeaderView.Stretch)
503
- treeHeader.setSectionResizeMode(self.C_COUNT, QHeaderView.ResizeToContents)
504
- treeHeader.setSectionResizeMode(self.C_ACTIVE, QHeaderView.Fixed)
505
- treeHeader.setSectionResizeMode(self.C_STATUS, QHeaderView.Fixed)
506
+ treeHeader.setSectionResizeMode(self.C_NAME, QHeaderView.ResizeMode.Stretch)
507
+ treeHeader.setSectionResizeMode(self.C_COUNT, QHeaderView.ResizeMode.ResizeToContents)
508
+ treeHeader.setSectionResizeMode(self.C_ACTIVE, QHeaderView.ResizeMode.Fixed)
509
+ treeHeader.setSectionResizeMode(self.C_STATUS, QHeaderView.ResizeMode.Fixed)
506
510
  treeHeader.resizeSection(self.C_ACTIVE, iPx + cMg)
507
511
  treeHeader.resizeSection(self.C_STATUS, iPx + cMg)
508
512
 
509
513
  # Allow Move by Drag & Drop
510
514
  self.setDragEnabled(True)
511
- self.setDragDropMode(QAbstractItemView.InternalMove)
512
- self.setDropIndicatorShown(True)
515
+ self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
513
516
 
514
- # Disable built-in autoscroll as it isn't working in some Qt
517
+ # Disable built-in auto scroll as it isn't working in some Qt
515
518
  # releases (see #1561) and instead use our own implementation
516
519
  self.setAutoScroll(False)
517
520
 
@@ -519,21 +522,21 @@ class GuiProjectTree(QTreeWidget):
519
522
  # Due to a bug, this stops working somewhere between Qt 5.15.3
520
523
  # and 5.15.8, so this is also blocked in dropEvent (see #1569)
521
524
  trRoot = self.invisibleRootItem()
522
- trRoot.setFlags(trRoot.flags() ^ Qt.ItemIsDropEnabled)
525
+ trRoot.setFlags(trRoot.flags() ^ Qt.ItemFlag.ItemIsDropEnabled)
523
526
 
524
527
  # Cached values
525
528
  self._lblActive = self.tr("Active")
526
529
  self._lblInactive = self.tr("Inactive")
527
530
 
528
531
  # Set selection options
529
- self.setSelectionMode(QAbstractItemView.SingleSelection)
530
- self.setSelectionBehavior(QAbstractItemView.SelectRows)
532
+ self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
533
+ self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
531
534
 
532
535
  # Connect signals
533
536
  self.itemDoubleClicked.connect(self._treeDoubleClick)
534
537
  self.itemSelectionChanged.connect(self._treeSelectionChange)
535
538
 
536
- # Autoscroll
539
+ # Auto Scroll
537
540
  self._scrollMargin = SHARED.theme.baseIconSize
538
541
  self._scrollDirection = 0
539
542
  self._scrollTimer = QTimer()
@@ -551,13 +554,13 @@ class GuiProjectTree(QTreeWidget):
551
554
  """Set or update tree widget settings."""
552
555
  # Scroll bars
553
556
  if CONFIG.hideVScroll:
554
- self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
557
+ self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
555
558
  else:
556
- self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
559
+ self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
557
560
  if CONFIG.hideHScroll:
558
- self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
561
+ self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
559
562
  else:
560
- self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
563
+ self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
561
564
  return
562
565
 
563
566
  ##
@@ -568,7 +571,6 @@ class GuiProjectTree(QTreeWidget):
568
571
  """Clear the GUI content and the related map."""
569
572
  self.clear()
570
573
  self._treeMap = {}
571
- self._lastMove = {}
572
574
  self._timeChanged = 0.0
573
575
  return
574
576
 
@@ -739,7 +741,6 @@ class GuiProjectTree(QTreeWidget):
739
741
 
740
742
  cItem = pItem.takeChild(tIndex)
741
743
  pItem.insertChild(nIndex, cItem)
742
- self._recordLastMove(cItem, pItem, tIndex)
743
744
 
744
745
  self._alertTreeChange(tHandle, flush=True)
745
746
  self.setCurrentItem(tItem)
@@ -768,19 +769,16 @@ class GuiProjectTree(QTreeWidget):
768
769
  self.setCurrentItem(tItem.child(0))
769
770
  return
770
771
 
771
- def renameTreeItem(self, tHandle: str) -> bool:
772
+ def renameTreeItem(self, tHandle: str, name: str = "") -> None:
772
773
  """Open a dialog to edit the label of an item."""
773
774
  tItem = SHARED.project.tree[tHandle]
774
- if tItem is None:
775
- return False
776
-
777
- newLabel, dlgOk = GuiEditLabel.getLabel(self, text=tItem.itemName)
778
- if dlgOk:
779
- tItem.setName(newLabel)
780
- self.setTreeItemValues(tHandle)
781
- self._alertTreeChange(tHandle, flush=False)
782
-
783
- return True
775
+ if tItem:
776
+ newLabel, dlgOk = GuiEditLabel.getLabel(self, text=name or tItem.itemName)
777
+ if dlgOk:
778
+ tItem.setName(newLabel)
779
+ self.setTreeItemValues(tHandle)
780
+ self._alertTreeChange(tHandle, flush=False)
781
+ return
784
782
 
785
783
  def saveTreeOrder(self) -> None:
786
784
  """Build a list of the items in the project tree and send them
@@ -843,6 +841,7 @@ class GuiProjectTree(QTreeWidget):
843
841
 
844
842
  return status
845
843
 
844
+ @pyqtSlot()
846
845
  def emptyTrash(self) -> bool:
847
846
  """Permanently delete all documents in the Trash folder. This
848
847
  function only asks for confirmation once, and calls the regular
@@ -921,15 +920,13 @@ class GuiProjectTree(QTreeWidget):
921
920
  logger.info("Action cancelled by user")
922
921
  return False
923
922
 
924
- wCount = self._getItemWordCount(tHandle)
925
923
  self.propagateCount(tHandle, 0)
926
924
 
927
925
  tIndex = trItemP.indexOfChild(trItemS)
928
926
  trItemC = trItemP.takeChild(tIndex)
929
927
  trItemT.addChild(trItemC)
930
928
 
931
- self._postItemMove(tHandle, wCount)
932
- self._recordLastMove(trItemS, trItemP, tIndex)
929
+ self._postItemMove(tHandle)
933
930
  self._alertTreeChange(tHandle, flush=flush)
934
931
 
935
932
  logger.debug("Moved item '%s' to Trash", tHandle)
@@ -1090,46 +1087,6 @@ class GuiProjectTree(QTreeWidget):
1090
1087
  logger.info("%d item(s) added to the project tree", count)
1091
1088
  return
1092
1089
 
1093
- def undoLastMove(self) -> bool:
1094
- """Attempt to undo the last action."""
1095
- srcItem = self._lastMove.get("item", None)
1096
- dstItem = self._lastMove.get("parent", None)
1097
- dstIndex = self._lastMove.get("index", None)
1098
-
1099
- srcOK = isinstance(srcItem, QTreeWidgetItem)
1100
- dstOk = isinstance(dstItem, QTreeWidgetItem)
1101
- if not srcOK or not dstOk or dstIndex is None:
1102
- logger.debug("No tree move to undo")
1103
- return False
1104
-
1105
- if srcItem not in self._treeMap.values():
1106
- logger.warning("Source item no longer exists")
1107
- return False
1108
-
1109
- if dstItem not in self._treeMap.values():
1110
- logger.warning("Previous parent item no longer exists")
1111
- return False
1112
-
1113
- dstIndex = min(max(0, dstIndex), dstItem.childCount())
1114
- sHandle = srcItem.data(self.C_DATA, self.D_HANDLE)
1115
- dHandle = dstItem.data(self.C_DATA, self.D_HANDLE)
1116
- logger.debug("Moving item '%s' back to '%s', index %d", sHandle, dHandle, dstIndex)
1117
-
1118
- wCount = self._getItemWordCount(sHandle)
1119
- self.propagateCount(sHandle, 0)
1120
- parItem = srcItem.parent()
1121
- srcIndex = parItem.indexOfChild(srcItem)
1122
- movItem = parItem.takeChild(srcIndex)
1123
- dstItem.insertChild(dstIndex, movItem)
1124
-
1125
- self._postItemMove(sHandle, wCount)
1126
- self._alertTreeChange(sHandle, flush=True)
1127
-
1128
- self.setCurrentItem(movItem)
1129
- self._lastMove = {}
1130
-
1131
- return True
1132
-
1133
1090
  def getSelectedHandle(self) -> str | None:
1134
1091
  """Get the currently selected handle. If multiple items are
1135
1092
  selected, return the first.
@@ -1150,7 +1107,7 @@ class GuiProjectTree(QTreeWidget):
1150
1107
 
1151
1108
  selIndex = self.selectedIndexes()
1152
1109
  if selIndex and doScroll:
1153
- self.scrollTo(selIndex[0], QAbstractItemView.PositionAtCenter)
1110
+ self.scrollTo(selIndex[0], QAbstractItemView.ScrollHint.PositionAtCenter)
1154
1111
 
1155
1112
  return True
1156
1113
 
@@ -1185,6 +1142,15 @@ class GuiProjectTree(QTreeWidget):
1185
1142
  tHandle = self.getSelectedHandle()
1186
1143
  if tHandle is not None:
1187
1144
  self.projView.selectedItemChanged.emit(tHandle)
1145
+
1146
+ # When selecting multiple items, don't allow including root
1147
+ # items in the selection and instead deselect them
1148
+ items = self.selectedItems()
1149
+ if items and len(items) > 1:
1150
+ for item in items:
1151
+ if item.parent() is None:
1152
+ item.setSelected(False)
1153
+
1188
1154
  return
1189
1155
 
1190
1156
  @pyqtSlot("QTreeWidgetItem*", int)
@@ -1215,160 +1181,29 @@ class GuiProjectTree(QTreeWidget):
1215
1181
  tItem = None
1216
1182
  tHandle = None
1217
1183
  hasChild = False
1218
- selItem = self.itemAt(clickPos)
1219
- if isinstance(selItem, QTreeWidgetItem):
1220
- tHandle = selItem.data(self.C_DATA, self.D_HANDLE)
1184
+ sItem = self.itemAt(clickPos)
1185
+ sItems = self.selectedItems()
1186
+ if isinstance(sItem, QTreeWidgetItem):
1187
+ tHandle = sItem.data(self.C_DATA, self.D_HANDLE)
1221
1188
  tItem = SHARED.project.tree[tHandle]
1222
- hasChild = selItem.childCount() > 0
1189
+ hasChild = sItem.childCount() > 0
1223
1190
 
1224
1191
  if tItem is None or tHandle is None:
1225
1192
  logger.debug("No item found")
1226
1193
  return False
1227
1194
 
1228
- ctxMenu = QMenu(self)
1229
-
1230
- # Trash Folder
1231
- # ============
1232
-
1195
+ ctxMenu = _TreeContextMenu(self, tItem)
1233
1196
  trashHandle = SHARED.project.tree.trashRoot
1234
- if tItem.itemHandle == trashHandle and trashHandle is not None:
1235
- # The trash folder only has one option
1236
- aEmptyTrash = ctxMenu.addAction(self.tr("Empty Trash"))
1237
- aEmptyTrash.triggered.connect(lambda: self.emptyTrash())
1238
- ctxMenu.exec_(self.viewport().mapToGlobal(clickPos))
1239
- return True
1240
-
1241
- # Document Actions
1242
- # ================
1243
-
1244
- isRoot = tItem.isRootType()
1245
- isFolder = tItem.isFolderType()
1246
- isFile = tItem.isFileType()
1247
-
1248
- if isFile:
1249
- aOpenDoc = ctxMenu.addAction(self.tr("Open Document"))
1250
- aOpenDoc.triggered.connect(
1251
- lambda: self.projView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, "", True)
1252
- )
1253
- aViewDoc = ctxMenu.addAction(self.tr("View Document"))
1254
- aViewDoc.triggered.connect(
1255
- lambda: self.projView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, "", False)
1256
- )
1257
- ctxMenu.addSeparator()
1258
-
1259
- # Edit Item Settings
1260
- # ==================
1261
-
1262
- aLabel = ctxMenu.addAction(self.tr("Rename"))
1263
- aLabel.triggered.connect(lambda: self.renameTreeItem(tHandle))
1264
-
1265
- if isFile:
1266
- aActive = ctxMenu.addAction(self.tr("Toggle Active"))
1267
- aActive.triggered.connect(lambda: self._toggleItemActive(tHandle))
1268
-
1269
- checkMark = f" ({nwUnicode.U_CHECK})"
1270
- if tItem.isNovelLike():
1271
- mStatus = ctxMenu.addMenu(self.tr("Set Status to ..."))
1272
- for n, (key, entry) in enumerate(SHARED.project.data.itemStatus.items()):
1273
- entryName = entry["name"] + (checkMark if tItem.itemStatus == key else "")
1274
- aStatus = mStatus.addAction(entry["icon"], entryName)
1275
- aStatus.triggered.connect(
1276
- lambda n, key=key: self._changeItemStatus(tHandle, key)
1277
- )
1278
- mStatus.addSeparator()
1279
- aManage1 = mStatus.addAction("Manage Labels ...")
1280
- aManage1.triggered.connect(
1281
- lambda: self.projView.projectSettingsRequest.emit(GuiProjectSettings.TAB_STATUS)
1282
- )
1283
- else:
1284
- mImport = ctxMenu.addMenu(self.tr("Set Importance to ..."))
1285
- for n, (key, entry) in enumerate(SHARED.project.data.itemImport.items()):
1286
- entryName = entry["name"] + (checkMark if tItem.itemImport == key else "")
1287
- aImport = mImport.addAction(entry["icon"], entryName)
1288
- aImport.triggered.connect(
1289
- lambda n, key=key: self._changeItemImport(tHandle, key)
1290
- )
1291
- mImport.addSeparator()
1292
- aManage2 = mImport.addAction("Manage Labels ...")
1293
- aManage2.triggered.connect(
1294
- lambda: self.projView.projectSettingsRequest.emit(GuiProjectSettings.TAB_IMPORT)
1295
- )
1296
-
1297
- # Transform Item
1298
- # ==============
1299
-
1300
- if not isRoot:
1301
- mTrans = ctxMenu.addMenu(self.tr("Transform"))
1302
-
1303
- trDoc = trConst(nwLabels.LAYOUT_NAME[nwItemLayout.DOCUMENT])
1304
- trNote = trConst(nwLabels.LAYOUT_NAME[nwItemLayout.NOTE])
1305
-
1306
- isDocFile = isFile and tItem.isDocumentLayout()
1307
- isNoteFile = isFile and tItem.isNoteLayout()
1308
-
1309
- if isNoteFile and tItem.documentAllowed():
1310
- aConvert1 = mTrans.addAction(self.tr("Convert to {0}").format(trDoc))
1311
- aConvert1.triggered.connect(
1312
- lambda: self._changeItemLayout(tHandle, nwItemLayout.DOCUMENT)
1313
- )
1314
-
1315
- if isDocFile:
1316
- aConvert2 = mTrans.addAction(self.tr("Convert to {0}").format(trNote))
1317
- aConvert2.triggered.connect(
1318
- lambda: self._changeItemLayout(tHandle, nwItemLayout.NOTE)
1319
- )
1320
-
1321
- if isFolder and tItem.documentAllowed():
1322
- aConvert3 = mTrans.addAction(self.tr("Convert to {0}").format(trDoc))
1323
- aConvert3.triggered.connect(
1324
- lambda: self._covertFolderToFile(tHandle, nwItemLayout.DOCUMENT)
1325
- )
1326
-
1327
- if isFolder:
1328
- aConvert4 = mTrans.addAction(self.tr("Convert to {0}").format(trNote))
1329
- aConvert4.triggered.connect(
1330
- lambda: self._covertFolderToFile(tHandle, nwItemLayout.NOTE)
1331
- )
1332
-
1333
- if hasChild and isFile:
1334
- aMerge1 = mTrans.addAction(self.tr("Merge Child Items into Self"))
1335
- aMerge1.triggered.connect(lambda: self._mergeDocuments(tHandle, False))
1336
- aMerge2 = mTrans.addAction(self.tr("Merge Child Items into New"))
1337
- aMerge2.triggered.connect(lambda: self._mergeDocuments(tHandle, True))
1338
-
1339
- if hasChild and isFolder:
1340
- aMerge3 = mTrans.addAction(self.tr("Merge Documents in Folder"))
1341
- aMerge3.triggered.connect(lambda: self._mergeDocuments(tHandle, True))
1342
-
1343
- if isFile:
1344
- aSplit1 = mTrans.addAction(self.tr("Split Document by Headers"))
1345
- aSplit1.triggered.connect(lambda: self._splitDocument(tHandle))
1346
-
1347
- # Expand/Collapse/Delete/Duplicate
1348
- # ================================
1349
-
1350
- ctxMenu.addSeparator()
1351
-
1352
- if hasChild:
1353
- aExpand = ctxMenu.addAction(self.tr("Expand All"))
1354
- aExpand.triggered.connect(lambda: self.setExpandedFromHandle(tHandle, True))
1355
- aCollapse = ctxMenu.addAction(self.tr("Collapse All"))
1356
- aCollapse.triggered.connect(lambda: self.setExpandedFromHandle(tHandle, False))
1357
- aDuplicate = ctxMenu.addAction(self.tr("Duplicate from Here"))
1358
- aDuplicate.triggered.connect(lambda: self._duplicateFromHandle(tHandle))
1359
- elif isFile:
1360
- aDuplicate = ctxMenu.addAction(self.tr("Duplicate Document"))
1361
- aDuplicate.triggered.connect(lambda: self._duplicateFromHandle(tHandle))
1362
-
1363
- if tItem.itemClass == nwItemClass.TRASH or isRoot or (isFolder and not hasChild):
1364
- aDelete = ctxMenu.addAction(self.tr("Delete Permanently"))
1365
- aDelete.triggered.connect(lambda: self.permDeleteItem(tHandle))
1197
+ if trashHandle and tHandle == trashHandle:
1198
+ ctxMenu.buildTrashMenu()
1199
+ elif len(sItems) > 1:
1200
+ handles = [str(x.data(self.C_DATA, self.D_HANDLE)) for x in sItems]
1201
+ ctxMenu.buildMultiSelectMenu(handles)
1366
1202
  else:
1367
- aMoveTrash = ctxMenu.addAction(self.tr("Move to Trash"))
1368
- aMoveTrash.triggered.connect(lambda: self.moveItemToTrash(tHandle))
1203
+ ctxMenu.buildSingleSelectMenu(hasChild)
1369
1204
 
1370
- # Show Context Menu
1371
1205
  ctxMenu.exec_(self.viewport().mapToGlobal(clickPos))
1206
+ ctxMenu.deleteLater()
1372
1207
 
1373
1208
  return True
1374
1209
 
@@ -1393,29 +1228,45 @@ class GuiProjectTree(QTreeWidget):
1393
1228
  for viewing if the user middle-clicked.
1394
1229
  """
1395
1230
  super().mousePressEvent(event)
1396
-
1397
- if event.button() == Qt.LeftButton:
1231
+ if event.button() == Qt.MouseButton.LeftButton:
1398
1232
  selItem = self.indexAt(event.pos())
1399
1233
  if not selItem.isValid():
1400
1234
  self.clearSelection()
1401
-
1402
- elif event.button() == Qt.MiddleButton:
1235
+ elif event.button() == Qt.MouseButton.MiddleButton:
1403
1236
  selItem = self.itemAt(event.pos())
1404
- if not isinstance(selItem, QTreeWidgetItem):
1405
- return
1406
-
1407
- tHandle = selItem.data(self.C_DATA, self.D_HANDLE)
1408
- tItem = SHARED.project.tree[tHandle]
1409
- if tItem is None:
1410
- return
1237
+ if selItem:
1238
+ tHandle = selItem.data(self.C_DATA, self.D_HANDLE)
1239
+ if (tItem := SHARED.project.tree[tHandle]) and tItem.isFileType():
1240
+ self.projView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, "", False)
1241
+ return
1411
1242
 
1412
- if tItem.isFileType():
1413
- self.projView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, "", False)
1243
+ def startDrag(self, dropAction: Qt.DropActions) -> None:
1244
+ """Capture the drag and drop handling to pop alerts."""
1245
+ super().startDrag(dropAction)
1246
+ if self._popAlert:
1247
+ SHARED.error(self._popAlert)
1248
+ self._popAlert = None
1249
+ return
1414
1250
 
1251
+ def dragEnterEvent(self, event: QDragEnterEvent) -> None:
1252
+ """Check that we're only dragging items that are siblings, and
1253
+ not a root level item.
1254
+ """
1255
+ items = self.selectedItems()
1256
+ if items and (parent := items[0].parent()) and all(x.parent() is parent for x in items):
1257
+ super().dragEnterEvent(event)
1258
+ else:
1259
+ logger.warning("Drag action is not allowed and has been cancelled")
1260
+ self._popAlert = self.tr(
1261
+ "Drag and drop is only allowed for single items, non-root "
1262
+ "items, or multiple items with the same parent."
1263
+ )
1264
+ event.mimeData().clear()
1265
+ event.ignore()
1415
1266
  return
1416
1267
 
1417
1268
  def dragMoveEvent(self, event: QDragMoveEvent) -> None:
1418
- """Capture the drag move event to enable edge autoscroll."""
1269
+ """Capture the drag move event to enable edge auto scroll."""
1419
1270
  y = event.pos().y()
1420
1271
  if y < self._scrollMargin:
1421
1272
  if not self._scrollTimer.isActive():
@@ -1429,38 +1280,33 @@ class GuiProjectTree(QTreeWidget):
1429
1280
  return
1430
1281
 
1431
1282
  def dropEvent(self, event: QDropEvent) -> None:
1432
- """Overload the drop item event to ensure relevant data has been
1433
- updated.
1283
+ """Overload the drop item event to ensure the drag and drop
1284
+ action is allowed, and update relevant data.
1434
1285
  """
1435
- sHandle = self.getSelectedHandle()
1436
- sItem = self._getTreeItem(sHandle) if sHandle else None
1437
- if sHandle is None or sItem is None or sItem.parent() is None:
1438
- logger.error("Invalid drag and drop event")
1439
- event.ignore()
1440
- return
1441
-
1442
- if not self.indexAt(event.pos()).isValid():
1443
- # Needed due to a bug somewhere around Qt 5.15.8 that
1444
- # ignores the invisible root item flags
1286
+ tItem = self.itemAt(event.pos())
1287
+ dropOn = self.dropIndicatorPosition() == QAbstractItemView.DropIndicatorPosition.OnItem
1288
+ # Make sure nothing can be dropped on invisible root (see #1569)
1289
+ if not tItem or tItem.parent() is None and not dropOn:
1445
1290
  logger.error("Invalid drop location")
1446
1291
  event.ignore()
1447
1292
  return
1448
1293
 
1449
- logger.debug("Drag'n'drop of item '%s' accepted", sHandle)
1294
+ mItems: dict[str, tuple[QTreeWidgetItem, bool]] = {}
1295
+ sItems = self.selectedItems()
1296
+ if sItems and (parent := sItems[0].parent()) and all(x.parent() is parent for x in sItems):
1297
+ for sItem in sItems:
1298
+ mHandle = str(sItem.data(self.C_DATA, self.D_HANDLE))
1299
+ mItems[mHandle] = (sItem, sItem.isExpanded())
1300
+ self.propagateCount(mHandle, 0)
1450
1301
 
1451
- isExpanded = sItem.isExpanded()
1452
- pItem = sItem.parent()
1453
- pIndex = pItem.indexOfChild(sItem) if pItem else 0
1302
+ super().dropEvent(event)
1454
1303
 
1455
- wCount = self._getItemWordCount(sHandle)
1456
- self.propagateCount(sHandle, 0)
1304
+ for mHandle, (sItem, isExpanded) in mItems.items():
1305
+ self._postItemMove(mHandle)
1306
+ sItem.setExpanded(isExpanded)
1307
+ self._alertTreeChange(mHandle, flush=False)
1457
1308
 
1458
- super().dropEvent(event)
1459
- self._postItemMove(sHandle, wCount)
1460
- self._recordLastMove(sItem, pItem, pIndex)
1461
- self._alertTreeChange(sHandle, flush=True)
1462
-
1463
- sItem.setExpanded(isExpanded)
1309
+ self.saveTreeOrder()
1464
1310
 
1465
1311
  return
1466
1312
 
@@ -1468,17 +1314,16 @@ class GuiProjectTree(QTreeWidget):
1468
1314
  # Internal Functions
1469
1315
  ##
1470
1316
 
1471
- def _postItemMove(self, tHandle: str, wCount: int) -> bool:
1317
+ def _postItemMove(self, tHandle: str) -> None:
1472
1318
  """Run various maintenance tasks for a moved item."""
1473
1319
  trItemS = self._getTreeItem(tHandle)
1474
1320
  nwItemS = SHARED.project.tree[tHandle]
1475
1321
  trItemP = trItemS.parent() if trItemS else None
1476
1322
  if trItemP is None or nwItemS is None:
1477
1323
  logger.error("Failed to find new parent item of '%s'", tHandle)
1478
- return False
1324
+ return
1479
1325
 
1480
- # Update item parent handle in the project, make sure meta data
1481
- # is updated accordingly, and update word count
1326
+ # Update item parent handle in the project
1482
1327
  pHandle = trItemP.data(self.C_DATA, self.D_HANDLE)
1483
1328
  nwItemS.setParent(pHandle)
1484
1329
  trItemP.setExpanded(True)
@@ -1489,19 +1334,16 @@ class GuiProjectTree(QTreeWidget):
1489
1334
  for mHandle in mHandles:
1490
1335
  logger.debug("Updating item '%s'", mHandle)
1491
1336
  SHARED.project.tree.updateItemData(mHandle)
1492
-
1493
- # Update the index
1494
1337
  if nwItemS.isInactiveClass():
1495
1338
  SHARED.project.index.deleteHandle(mHandle)
1496
1339
  else:
1497
1340
  SHARED.project.index.reIndexHandle(mHandle)
1498
-
1499
1341
  self.setTreeItemValues(mHandle)
1500
1342
 
1501
- # Trigger dependent updates
1502
- self.propagateCount(tHandle, wCount)
1343
+ # Update word count
1344
+ self.propagateCount(tHandle, nwItemS.wordCount, countChildren=True)
1503
1345
 
1504
- return True
1346
+ return
1505
1347
 
1506
1348
  def _getItemWordCount(self, tHandle: str) -> int:
1507
1349
  """Return the word count of a given item handle."""
@@ -1512,15 +1354,6 @@ class GuiProjectTree(QTreeWidget):
1512
1354
  """Return the QTreeWidgetItem of a given item handle."""
1513
1355
  return self._treeMap.get(tHandle, None) if tHandle else None
1514
1356
 
1515
- def _toggleItemActive(self, tHandle: str) -> None:
1516
- """Toggle the active status of an item."""
1517
- tItem = SHARED.project.tree[tHandle]
1518
- if tItem is not None:
1519
- tItem.setActive(not tItem.isActive)
1520
- self.setTreeItemValues(tItem.itemHandle)
1521
- self._alertTreeChange(tHandle, flush=False)
1522
- return
1523
-
1524
1357
  def _recursiveSetExpanded(self, trItem: QTreeWidgetItem, isExpanded: bool) -> None:
1525
1358
  """Recursive function to set expanded status starting from (and
1526
1359
  not including) a given item.
@@ -1533,60 +1366,6 @@ class GuiProjectTree(QTreeWidget):
1533
1366
  self._recursiveSetExpanded(chItem, isExpanded)
1534
1367
  return
1535
1368
 
1536
- def _changeItemStatus(self, tHandle: str, tStatus: str) -> None:
1537
- """Set a new status value of an item."""
1538
- tItem = SHARED.project.tree[tHandle]
1539
- if tItem is not None:
1540
- tItem.setStatus(tStatus)
1541
- self.setTreeItemValues(tItem.itemHandle)
1542
- self._alertTreeChange(tHandle, flush=False)
1543
- return
1544
-
1545
- def _changeItemImport(self, tHandle: str, tImport: str) -> None:
1546
- """Set a new importance value of an item."""
1547
- tItem = SHARED.project.tree[tHandle]
1548
- if tItem is not None:
1549
- tItem.setImport(tImport)
1550
- self.setTreeItemValues(tItem.itemHandle)
1551
- self._alertTreeChange(tHandle, flush=False)
1552
- return
1553
-
1554
- def _changeItemLayout(self, tHandle: str, itemLayout: nwItemLayout) -> None:
1555
- """Set a new item layout value of an item."""
1556
- tItem = SHARED.project.tree[tHandle]
1557
- if tItem is not None:
1558
- if itemLayout == nwItemLayout.DOCUMENT and tItem.documentAllowed():
1559
- tItem.setLayout(nwItemLayout.DOCUMENT)
1560
- self.setTreeItemValues(tHandle)
1561
- self._alertTreeChange(tHandle, flush=False)
1562
- elif itemLayout == nwItemLayout.NOTE:
1563
- tItem.setLayout(nwItemLayout.NOTE)
1564
- self.setTreeItemValues(tHandle)
1565
- self._alertTreeChange(tHandle, flush=False)
1566
- return
1567
-
1568
- def _covertFolderToFile(self, tHandle: str, itemLayout: nwItemLayout) -> None:
1569
- """Convert a folder to a note or document."""
1570
- tItem = SHARED.project.tree[tHandle]
1571
- if tItem is not None and tItem.isFolderType():
1572
- msgYes = SHARED.question(self.tr(
1573
- "Do you want to convert the folder to a {0}? "
1574
- "This action cannot be reversed."
1575
- ).format(trConst(nwLabels.LAYOUT_NAME[itemLayout])))
1576
- if msgYes and itemLayout == nwItemLayout.DOCUMENT and tItem.documentAllowed():
1577
- tItem.setType(nwItemType.FILE)
1578
- tItem.setLayout(nwItemLayout.DOCUMENT)
1579
- self.setTreeItemValues(tHandle)
1580
- self._alertTreeChange(tHandle, flush=False)
1581
- elif msgYes and itemLayout == nwItemLayout.NOTE:
1582
- tItem.setType(nwItemType.FILE)
1583
- tItem.setLayout(nwItemLayout.NOTE)
1584
- self.setTreeItemValues(tHandle)
1585
- self._alertTreeChange(tHandle, flush=False)
1586
- else:
1587
- logger.info("Folder conversion cancelled")
1588
- return
1589
-
1590
1369
  def _mergeDocuments(self, tHandle: str, newFile: bool) -> bool:
1591
1370
  """Merge an item's child documents into a single document."""
1592
1371
  logger.info("Request to merge items under handle '%s'", tHandle)
@@ -1606,7 +1385,7 @@ class GuiProjectTree(QTreeWidget):
1606
1385
  dlgMerge = GuiDocMerge(self.mainGui, tHandle, itemList)
1607
1386
  dlgMerge.exec_()
1608
1387
 
1609
- if dlgMerge.result() == QDialog.Accepted:
1388
+ if dlgMerge.result() == QDialog.DialogCode.Accepted:
1610
1389
 
1611
1390
  mrgData = dlgMerge.getData()
1612
1391
  mrgList = mrgData.get("finalItems", [])
@@ -1676,7 +1455,7 @@ class GuiProjectTree(QTreeWidget):
1676
1455
  dlgSplit = GuiDocSplit(self.mainGui, tHandle)
1677
1456
  dlgSplit.exec_()
1678
1457
 
1679
- if dlgSplit.result() == QDialog.Accepted:
1458
+ if dlgSplit.result() == QDialog.DialogCode.Accepted:
1680
1459
 
1681
1460
  splitData, splitText = dlgSplit.getData()
1682
1461
 
@@ -1781,10 +1560,10 @@ class GuiProjectTree(QTreeWidget):
1781
1560
  newItem.setText(self.C_ACTIVE, "")
1782
1561
  newItem.setText(self.C_STATUS, "")
1783
1562
 
1784
- newItem.setTextAlignment(self.C_NAME, Qt.AlignLeft)
1785
- newItem.setTextAlignment(self.C_COUNT, Qt.AlignRight)
1786
- newItem.setTextAlignment(self.C_ACTIVE, Qt.AlignLeft)
1787
- newItem.setTextAlignment(self.C_STATUS, Qt.AlignLeft)
1563
+ newItem.setTextAlignment(self.C_NAME, Qt.AlignmentFlag.AlignLeft)
1564
+ newItem.setTextAlignment(self.C_COUNT, Qt.AlignmentFlag.AlignRight)
1565
+ newItem.setTextAlignment(self.C_ACTIVE, Qt.AlignmentFlag.AlignLeft)
1566
+ newItem.setTextAlignment(self.C_STATUS, Qt.AlignmentFlag.AlignLeft)
1788
1567
 
1789
1568
  newItem.setData(self.C_DATA, self.D_HANDLE, tHandle)
1790
1569
  newItem.setData(self.C_DATA, self.D_WORDS, 0)
@@ -1852,16 +1631,322 @@ class GuiProjectTree(QTreeWidget):
1852
1631
 
1853
1632
  return
1854
1633
 
1855
- def _recordLastMove(self, srcItem: QTreeWidgetItem,
1856
- parItem: QTreeWidgetItem, parIndex: int) -> None:
1857
- """Record the last action so that it can be undone."""
1858
- prevItem = self._lastMove.get("item", None)
1859
- if prevItem is None or srcItem != prevItem:
1860
- self._lastMove = {
1861
- "item": srcItem,
1862
- "parent": parItem,
1863
- "index": parIndex,
1864
- }
1634
+ # END Class GuiProjectTree
1635
+
1636
+
1637
+ class _TreeContextMenu(QMenu):
1638
+
1639
+ def __init__(self, projTree: GuiProjectTree, nwItem: NWItem) -> None:
1640
+ super().__init__(parent=projTree)
1641
+
1642
+ self.projTree = projTree
1643
+ self.projView = projTree.projView
1644
+
1645
+ self._item = nwItem
1646
+ self._handle = nwItem.itemHandle
1647
+ self._items: list[str] = []
1648
+
1649
+ logger.debug("Ready: _TreeContextMenu")
1650
+
1865
1651
  return
1866
1652
 
1867
- # END Class GuiProjectTree
1653
+ def __del__(self): # pragma: no cover
1654
+ logger.debug("Delete: _TreeContextMenu")
1655
+ return
1656
+
1657
+ ##
1658
+ # Methods
1659
+ ##
1660
+
1661
+ def buildTrashMenu(self) -> None:
1662
+ """Build the special menu for the Trash folder."""
1663
+ action = self.addAction(self.tr("Empty Trash"))
1664
+ action.triggered.connect(self.projTree.emptyTrash)
1665
+ return
1666
+
1667
+ def buildSingleSelectMenu(self, hasChild: bool) -> None:
1668
+ """Build the single-select menu."""
1669
+ isFile = self._item.isFileType()
1670
+ isFolder = self._item.isFolderType()
1671
+ isRoot = self._item.isRootType()
1672
+
1673
+ # Document Actions
1674
+ if isFile:
1675
+ self._docActions()
1676
+ self.addSeparator()
1677
+
1678
+ # Edit Item Settings
1679
+ aLabel = self.addAction(self.tr("Rename"))
1680
+ aLabel.triggered.connect(lambda: self.projTree.renameTreeItem(self._handle))
1681
+ if isFile:
1682
+ self._itemActive(False)
1683
+ self._itemStatusImport(False)
1684
+
1685
+ # Transform Item
1686
+ if isFile or isFolder:
1687
+ self._itemTransform(isFile, isFolder, hasChild)
1688
+ self.addSeparator()
1689
+
1690
+ # Process Item
1691
+ self._itemProcess(isFile, isFolder, isRoot, hasChild)
1692
+
1693
+ return
1694
+
1695
+ def buildMultiSelectMenu(self, items: list[str]) -> None:
1696
+ """Build the multi-select menu."""
1697
+ self._items = items
1698
+ self._itemActive(True)
1699
+ self._itemStatusImport(True)
1700
+ self.addSeparator()
1701
+ self._moveToTrash(True)
1702
+ return
1703
+
1704
+ ##
1705
+ # Menu Builders
1706
+ ##
1707
+
1708
+ def _docActions(self) -> None:
1709
+ """Add document actions."""
1710
+ action = self.addAction(self.tr("Open Document"))
1711
+ action.triggered.connect(
1712
+ lambda: self.projView.openDocumentRequest.emit(self._handle, nwDocMode.EDIT, "", True)
1713
+ )
1714
+ action = self.addAction(self.tr("View Document"))
1715
+ action.triggered.connect(
1716
+ lambda: self.projView.openDocumentRequest.emit(self._handle, nwDocMode.VIEW, "", False)
1717
+ )
1718
+ return
1719
+
1720
+ def _itemActive(self, multi: bool) -> None:
1721
+ """Add Active/Inactive actions."""
1722
+ if multi:
1723
+ mSub = self.addMenu(self.tr("Set Active to ..."))
1724
+ aOne = mSub.addAction(SHARED.theme.getIcon("checked"), self.tr("Active"))
1725
+ aOne.triggered.connect(lambda: self._iterItemActive(True))
1726
+ aTwo = mSub.addAction(SHARED.theme.getIcon("unchecked"), self.tr("Inactive"))
1727
+ aTwo.triggered.connect(lambda: self._iterItemActive(False))
1728
+ else:
1729
+ action = self.addAction(self.tr("Toggle Active"))
1730
+ action.triggered.connect(self._toggleItemActive)
1731
+ return
1732
+
1733
+ def _itemStatusImport(self, multi: bool) -> None:
1734
+ """Add actions for changing status or importance."""
1735
+ if self._item.isNovelLike():
1736
+ menu = self.addMenu(self.tr("Set Status to ..."))
1737
+ current = self._item.itemStatus
1738
+ for n, (key, entry) in enumerate(SHARED.project.data.itemStatus.items()):
1739
+ name = entry["name"]
1740
+ if not multi and current == key:
1741
+ name += f" ({nwUnicode.U_CHECK})"
1742
+ action = menu.addAction(entry["icon"], name)
1743
+ if multi:
1744
+ action.triggered.connect(lambda n, key=key: self._iterSetItemStatus(key))
1745
+ else:
1746
+ action.triggered.connect(lambda n, key=key: self._changeItemStatus(key))
1747
+ menu.addSeparator()
1748
+ action = menu.addAction(self.tr("Manage Labels ..."))
1749
+ action.triggered.connect(
1750
+ lambda: self.projView.projectSettingsRequest.emit(GuiProjectSettings.TAB_STATUS)
1751
+ )
1752
+ else:
1753
+ menu = self.addMenu(self.tr("Set Importance to ..."))
1754
+ current = self._item.itemImport
1755
+ for n, (key, entry) in enumerate(SHARED.project.data.itemImport.items()):
1756
+ name = entry["name"]
1757
+ if not multi and current == key:
1758
+ name += f" ({nwUnicode.U_CHECK})"
1759
+ action = menu.addAction(entry["icon"], name)
1760
+ if multi:
1761
+ action.triggered.connect(lambda n, key=key: self._iterSetItemImport(key))
1762
+ else:
1763
+ action.triggered.connect(lambda n, key=key: self._changeItemImport(key))
1764
+ menu.addSeparator()
1765
+ action = menu.addAction(self.tr("Manage Labels ..."))
1766
+ action.triggered.connect(
1767
+ lambda: self.projView.projectSettingsRequest.emit(GuiProjectSettings.TAB_IMPORT)
1768
+ )
1769
+ return
1770
+
1771
+ def _itemTransform(self, isFile: bool, isFolder: bool, hasChild: bool) -> None:
1772
+ """Add actions for the Transform menu."""
1773
+ menu = self.addMenu(self.tr("Transform"))
1774
+
1775
+ tree = self.projTree
1776
+ tHandle = self._handle
1777
+
1778
+ trDoc = trConst(nwLabels.LAYOUT_NAME[nwItemLayout.DOCUMENT])
1779
+ trNote = trConst(nwLabels.LAYOUT_NAME[nwItemLayout.NOTE])
1780
+ loDoc = nwItemLayout.DOCUMENT
1781
+ loNote = nwItemLayout.NOTE
1782
+ isDocFile = isFile and self._item.isDocumentLayout()
1783
+ isNoteFile = isFile and self._item.isNoteLayout()
1784
+
1785
+ if isNoteFile and self._item.documentAllowed():
1786
+ action = menu.addAction(self.tr("Convert to {0}").format(trDoc))
1787
+ action.triggered.connect(lambda: self._changeItemLayout(loDoc))
1788
+
1789
+ if isDocFile:
1790
+ action = menu.addAction(self.tr("Convert to {0}").format(trNote))
1791
+ action.triggered.connect(lambda: self._changeItemLayout(loNote))
1792
+
1793
+ if isFolder and self._item.documentAllowed():
1794
+ action = menu.addAction(self.tr("Convert to {0}").format(trDoc))
1795
+ action.triggered.connect(lambda: self._covertFolderToFile(loDoc))
1796
+
1797
+ if isFolder:
1798
+ action = menu.addAction(self.tr("Convert to {0}").format(trNote))
1799
+ action.triggered.connect(lambda: self._covertFolderToFile(loNote))
1800
+
1801
+ if hasChild and isFile:
1802
+ action = menu.addAction(self.tr("Merge Child Items into Self"))
1803
+ action.triggered.connect(lambda: tree._mergeDocuments(tHandle, False))
1804
+ action = menu.addAction(self.tr("Merge Child Items into New"))
1805
+ action.triggered.connect(lambda: tree._mergeDocuments(tHandle, True))
1806
+
1807
+ if hasChild and isFolder:
1808
+ action = menu.addAction(self.tr("Merge Documents in Folder"))
1809
+ action.triggered.connect(lambda: tree._mergeDocuments(tHandle, True))
1810
+
1811
+ if isFile:
1812
+ action = menu.addAction(self.tr("Split Document by Headers"))
1813
+ action.triggered.connect(lambda: tree._splitDocument(tHandle))
1814
+
1815
+ return
1816
+
1817
+ def _itemProcess(self, isFile: bool, isFolder: bool, isRoot: bool, hasChild: bool) -> None:
1818
+ """Add actions for item processing."""
1819
+ tree = self.projTree
1820
+ tHandle = self._handle
1821
+ if hasChild:
1822
+ action = self.addAction(self.tr("Expand All"))
1823
+ action.triggered.connect(lambda: tree.setExpandedFromHandle(tHandle, True))
1824
+ action = self.addAction(self.tr("Collapse All"))
1825
+ action.triggered.connect(lambda: tree.setExpandedFromHandle(tHandle, False))
1826
+ action = self.addAction(self.tr("Duplicate from Here"))
1827
+ action.triggered.connect(lambda: tree._duplicateFromHandle(tHandle))
1828
+ elif isFile:
1829
+ action = self.addAction(self.tr("Duplicate Document"))
1830
+ action.triggered.connect(lambda: tree._duplicateFromHandle(tHandle))
1831
+
1832
+ if self._item.itemClass == nwItemClass.TRASH or isRoot or (isFolder and not hasChild):
1833
+ action = self.addAction(self.tr("Delete Permanently"))
1834
+ action.triggered.connect(lambda: tree.permDeleteItem(tHandle))
1835
+ else:
1836
+ action = self.addAction(self.tr("Move to Trash"))
1837
+ action.triggered.connect(lambda: tree.moveItemToTrash(tHandle))
1838
+
1839
+ return
1840
+
1841
+ def _moveToTrash(self, multi: bool) -> None:
1842
+ """Add move to Trash action."""
1843
+ action = self.addAction(self.tr("Move to Trash"))
1844
+ if multi:
1845
+ action.triggered.connect(self._iterMoveToTrash)
1846
+ return
1847
+
1848
+ ##
1849
+ # Private Slots
1850
+ ##
1851
+
1852
+ @pyqtSlot()
1853
+ def _iterMoveToTrash(self) -> None:
1854
+ """Iterate through files and move them to Trash."""
1855
+ if SHARED.question(self.tr("Move {0} items to Trash?").format(len(self._items))):
1856
+ for tHandle in self._items:
1857
+ tItem = SHARED.project.tree[tHandle]
1858
+ if tItem and tItem.isFileType():
1859
+ self.projTree.moveItemToTrash(tHandle, askFirst=False, flush=False)
1860
+ self.projTree.saveTreeOrder()
1861
+ return
1862
+
1863
+ @pyqtSlot()
1864
+ def _toggleItemActive(self) -> None:
1865
+ """Toggle the active status of an item."""
1866
+ self._item.setActive(not self._item.isActive)
1867
+ self.projTree.setTreeItemValues(self._handle)
1868
+ self.projTree._alertTreeChange(self._handle, flush=False)
1869
+ return
1870
+
1871
+ ##
1872
+ # Internal Functions
1873
+ ##
1874
+
1875
+ def _iterItemActive(self, isActive: bool) -> None:
1876
+ """Set the active status of multiple items."""
1877
+ for tHandle in self._items:
1878
+ tItem = SHARED.project.tree[tHandle]
1879
+ if tItem and tItem.isFileType():
1880
+ tItem.setActive(isActive)
1881
+ self.projTree.setTreeItemValues(tHandle)
1882
+ self.projTree._alertTreeChange(tHandle, flush=False)
1883
+ return
1884
+
1885
+ def _changeItemStatus(self, key: str) -> None:
1886
+ """Set a new status value of an item."""
1887
+ self._item.setStatus(key)
1888
+ self.projTree.setTreeItemValues(self._handle)
1889
+ self.projTree._alertTreeChange(self._handle, flush=False)
1890
+ return
1891
+
1892
+ def _iterSetItemStatus(self, key: str) -> None:
1893
+ """Change the status value for multiple items."""
1894
+ for tHandle in self._items:
1895
+ tItem = SHARED.project.tree[tHandle]
1896
+ if tItem and tItem.isNovelLike():
1897
+ tItem.setStatus(key)
1898
+ self.projTree.setTreeItemValues(tHandle)
1899
+ self.projTree._alertTreeChange(tHandle, flush=False)
1900
+ return
1901
+
1902
+ def _changeItemImport(self, key: str) -> None:
1903
+ """Set a new importance value of an item."""
1904
+ self._item.setImport(key)
1905
+ self.projTree.setTreeItemValues(self._handle)
1906
+ self.projTree._alertTreeChange(self._handle, flush=False)
1907
+ return
1908
+
1909
+ def _iterSetItemImport(self, key: str) -> None:
1910
+ """Change the status value for multiple items."""
1911
+ for tHandle in self._items:
1912
+ tItem = SHARED.project.tree[tHandle]
1913
+ if tItem and not tItem.isNovelLike():
1914
+ tItem.setImport(key)
1915
+ self.projTree.setTreeItemValues(tHandle)
1916
+ self.projTree._alertTreeChange(tHandle, flush=False)
1917
+ return
1918
+
1919
+ def _changeItemLayout(self, itemLayout: nwItemLayout) -> None:
1920
+ """Set a new item layout value of an item."""
1921
+ if itemLayout == nwItemLayout.DOCUMENT and self._item.documentAllowed():
1922
+ self._item.setLayout(nwItemLayout.DOCUMENT)
1923
+ self.projTree.setTreeItemValues(self._handle)
1924
+ self.projTree._alertTreeChange(self._handle, flush=False)
1925
+ elif itemLayout == nwItemLayout.NOTE:
1926
+ self._item.setLayout(nwItemLayout.NOTE)
1927
+ self.projTree.setTreeItemValues(self._handle)
1928
+ self.projTree._alertTreeChange(self._handle, flush=False)
1929
+ return
1930
+
1931
+ def _covertFolderToFile(self, itemLayout: nwItemLayout) -> None:
1932
+ """Convert a folder to a note or document."""
1933
+ if self._item.isFolderType():
1934
+ msgYes = SHARED.question(self.tr(
1935
+ "Do you want to convert the folder to a {0}? "
1936
+ "This action cannot be reversed."
1937
+ ).format(trConst(nwLabels.LAYOUT_NAME[itemLayout])))
1938
+ if msgYes and itemLayout == nwItemLayout.DOCUMENT and self._item.documentAllowed():
1939
+ self._item.setType(nwItemType.FILE)
1940
+ self._item.setLayout(nwItemLayout.DOCUMENT)
1941
+ self.projTree.setTreeItemValues(self._handle)
1942
+ self.projTree._alertTreeChange(self._handle, flush=False)
1943
+ elif msgYes and itemLayout == nwItemLayout.NOTE:
1944
+ self._item.setType(nwItemType.FILE)
1945
+ self._item.setLayout(nwItemLayout.NOTE)
1946
+ self.projTree.setTreeItemValues(self._handle)
1947
+ self.projTree._alertTreeChange(self._handle, flush=False)
1948
+ else:
1949
+ logger.info("Folder conversion cancelled")
1950
+ return
1951
+
1952
+ # END Class _TreeContextMenu