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