novelWriter 2.1.1__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 (109) hide show
  1. {novelWriter-2.1.1.dist-info → novelWriter-2.2rc1.dist-info}/METADATA +3 -3
  2. {novelWriter-2.1.1.dist-info → novelWriter-2.2rc1.dist-info}/RECORD +105 -76
  3. novelwriter/__init__.py +6 -24
  4. novelwriter/assets/i18n/project_de_DE.json +10 -0
  5. novelwriter/assets/i18n/project_en_GB.json +11 -0
  6. novelwriter/assets/i18n/project_en_US.json +10 -0
  7. novelwriter/assets/i18n/project_ja_JP.json +11 -1
  8. novelwriter/assets/i18n/project_nb_NO.json +10 -0
  9. novelwriter/assets/i18n/project_nn_NO.json +10 -0
  10. novelwriter/assets/icons/novelwriter.ico +0 -0
  11. novelwriter/assets/icons/novelwriter.svg +8 -183
  12. novelwriter/assets/icons/typicons_dark/icons.conf +17 -2
  13. novelwriter/assets/icons/typicons_dark/nw_deco-h2-narrow.svg +4 -0
  14. novelwriter/assets/icons/typicons_dark/nw_deco-h3-narrow.svg +4 -0
  15. novelwriter/assets/icons/typicons_dark/nw_deco-h4-narrow.svg +4 -0
  16. novelwriter/assets/icons/typicons_dark/nw_deco-note.svg +4 -0
  17. novelwriter/assets/icons/typicons_dark/nw_panel.svg +4 -0
  18. novelwriter/assets/icons/typicons_dark/nw_tb-bold.svg +4 -0
  19. novelwriter/assets/icons/typicons_dark/nw_tb-italic.svg +4 -0
  20. novelwriter/assets/icons/typicons_dark/nw_tb-markdown.svg +8 -0
  21. novelwriter/assets/icons/typicons_dark/nw_tb-shortcode.svg +8 -0
  22. novelwriter/assets/icons/typicons_dark/nw_tb-strike.svg +4 -0
  23. novelwriter/assets/icons/typicons_dark/nw_tb-subscript.svg +5 -0
  24. novelwriter/assets/icons/typicons_dark/nw_tb-superscript.svg +5 -0
  25. novelwriter/assets/icons/typicons_dark/nw_tb-underline.svg +5 -0
  26. novelwriter/assets/icons/typicons_dark/typ_eye.svg +4 -0
  27. novelwriter/assets/icons/typicons_dark/typ_th-dot-menu.svg +4 -0
  28. novelwriter/assets/icons/typicons_light/icons.conf +17 -2
  29. novelwriter/assets/icons/typicons_light/nw_deco-h2-narrow.svg +4 -0
  30. novelwriter/assets/icons/typicons_light/nw_deco-h3-narrow.svg +4 -0
  31. novelwriter/assets/icons/typicons_light/nw_deco-h4-narrow.svg +4 -0
  32. novelwriter/assets/icons/typicons_light/nw_deco-note.svg +4 -0
  33. novelwriter/assets/icons/typicons_light/nw_panel.svg +4 -0
  34. novelwriter/assets/icons/typicons_light/nw_tb-bold.svg +4 -0
  35. novelwriter/assets/icons/typicons_light/nw_tb-italic.svg +4 -0
  36. novelwriter/assets/icons/typicons_light/nw_tb-markdown.svg +8 -0
  37. novelwriter/assets/icons/typicons_light/nw_tb-shortcode.svg +8 -0
  38. novelwriter/assets/icons/typicons_light/nw_tb-strike.svg +4 -0
  39. novelwriter/assets/icons/typicons_light/nw_tb-subscript.svg +5 -0
  40. novelwriter/assets/icons/typicons_light/nw_tb-superscript.svg +5 -0
  41. novelwriter/assets/icons/typicons_light/nw_tb-underline.svg +5 -0
  42. novelwriter/assets/icons/typicons_light/typ_eye.svg +4 -0
  43. novelwriter/assets/icons/typicons_light/typ_th-dot-menu.svg +4 -0
  44. novelwriter/assets/icons/x-novelwriter-project.ico +0 -0
  45. novelwriter/assets/icons/x-novelwriter-project.svg +7 -206
  46. novelwriter/assets/manual.pdf +0 -0
  47. novelwriter/assets/sample.zip +0 -0
  48. novelwriter/assets/syntax/default_dark.conf +1 -0
  49. novelwriter/assets/syntax/default_light.conf +1 -0
  50. novelwriter/assets/syntax/grey_dark.conf +1 -0
  51. novelwriter/assets/syntax/grey_light.conf +1 -0
  52. novelwriter/assets/syntax/light_owl.conf +1 -0
  53. novelwriter/assets/syntax/night_owl.conf +1 -0
  54. novelwriter/assets/syntax/solarized_dark.conf +1 -0
  55. novelwriter/assets/syntax/solarized_light.conf +1 -0
  56. novelwriter/assets/syntax/tomorrow.conf +1 -0
  57. novelwriter/assets/syntax/tomorrow_night.conf +1 -0
  58. novelwriter/assets/syntax/tomorrow_night_blue.conf +1 -0
  59. novelwriter/assets/syntax/tomorrow_night_bright.conf +1 -0
  60. novelwriter/assets/syntax/tomorrow_night_eighties.conf +1 -0
  61. novelwriter/assets/text/credits_en.htm +7 -0
  62. novelwriter/assets/text/release_notes.htm +7 -37
  63. novelwriter/common.py +22 -1
  64. novelwriter/config.py +27 -42
  65. novelwriter/constants.py +45 -7
  66. novelwriter/core/buildsettings.py +40 -24
  67. novelwriter/core/coretools.py +8 -1
  68. novelwriter/core/docbuild.py +2 -6
  69. novelwriter/core/index.py +264 -175
  70. novelwriter/core/options.py +8 -3
  71. novelwriter/core/project.py +2 -2
  72. novelwriter/core/projectdata.py +3 -3
  73. novelwriter/core/tohtml.py +60 -59
  74. novelwriter/core/tokenizer.py +110 -70
  75. novelwriter/core/tomd.py +51 -38
  76. novelwriter/core/toodt.py +184 -147
  77. novelwriter/dialogs/preferences.py +75 -106
  78. novelwriter/dialogs/projsettings.py +101 -110
  79. novelwriter/dialogs/updates.py +25 -14
  80. novelwriter/enum.py +28 -3
  81. novelwriter/extensions/novelselector.py +1 -1
  82. novelwriter/gui/doceditor.py +1345 -1235
  83. novelwriter/gui/dochighlight.py +98 -62
  84. novelwriter/gui/docviewer.py +151 -340
  85. novelwriter/gui/docviewerpanel.py +457 -0
  86. novelwriter/gui/editordocument.py +126 -0
  87. novelwriter/gui/mainmenu.py +350 -300
  88. novelwriter/gui/noveltree.py +101 -125
  89. novelwriter/gui/outline.py +154 -171
  90. novelwriter/gui/projtree.py +480 -380
  91. novelwriter/gui/sidebar.py +106 -75
  92. novelwriter/gui/statusbar.py +1 -1
  93. novelwriter/gui/theme.py +114 -75
  94. novelwriter/guimain.py +353 -254
  95. novelwriter/shared.py +36 -3
  96. novelwriter/tools/dictionaries.py +268 -0
  97. novelwriter/tools/manusbuild.py +17 -6
  98. novelwriter/tools/manuscript.py +11 -3
  99. novelwriter/tools/manussettings.py +0 -14
  100. novelwriter/tools/projwizard.py +16 -2
  101. novelwriter/tools/writingstats.py +1 -1
  102. novelwriter/assets/icons/typicons_dark/typ_at.svg +0 -4
  103. novelwriter/assets/icons/typicons_dark/typ_th-menu.svg +0 -4
  104. novelwriter/assets/icons/typicons_light/typ_at.svg +0 -4
  105. novelwriter/assets/icons/typicons_light/typ_th-menu.svg +0 -4
  106. {novelWriter-2.1.1.dist-info → novelWriter-2.2rc1.dist-info}/LICENSE.md +0 -0
  107. {novelWriter-2.1.1.dist-info → novelWriter-2.2rc1.dist-info}/WHEEL +0 -0
  108. {novelWriter-2.1.1.dist-info → novelWriter-2.2rc1.dist-info}/entry_points.txt +0 -0
  109. {novelWriter-2.1.1.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,11 +137,9 @@ 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
142
+ self.createNewNote = self.projTree.createNewNote
150
143
 
151
144
  return
152
145
 
@@ -188,7 +181,7 @@ class GuiProjectView(QWidget):
188
181
  self.projTree.buildTree()
189
182
  return
190
183
 
191
- def setFocus(self) -> None:
184
+ def setTreeFocus(self) -> None:
192
185
  """Forward the set focus call to the tree widget."""
193
186
  self.projTree.setFocus()
194
187
  return
@@ -197,17 +190,32 @@ class GuiProjectView(QWidget):
197
190
  """Check if the project tree has focus."""
198
191
  return self.projTree.hasFocus()
199
192
 
200
- 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:
201
199
  """External request to rename an item or the currently selected
202
200
  item. This is triggered by the global menu or keyboard shortcut.
203
201
  """
204
202
  if tHandle is None:
205
203
  tHandle = self.projTree.getSelectedHandle()
206
- return self.projTree.renameTreeItem(tHandle) if tHandle else False
204
+ if tHandle:
205
+ self.projTree.renameTreeItem(tHandle, name=name)
206
+ return
207
207
 
208
- ##
209
- # Public Slots
210
- ##
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
211
219
 
212
220
  @pyqtSlot(str, int, int, int)
213
221
  def updateCounts(self, tHandle: str, cCount: int, wCount: int, pCount: int) -> None:
@@ -255,7 +263,7 @@ class GuiProjectToolBar(QWidget):
255
263
  self.tbQuick.setShortcut("Ctrl+L")
256
264
  self.tbQuick.setIconSize(QSize(iPx, iPx))
257
265
  self.tbQuick.setMenu(self.mQuick)
258
- self.tbQuick.setPopupMode(QToolButton.InstantPopup)
266
+ self.tbQuick.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
259
267
 
260
268
  # Move Buttons
261
269
  self.tbMoveU = QToolButton(self)
@@ -304,7 +312,7 @@ class GuiProjectToolBar(QWidget):
304
312
  self.tbAdd.setShortcut("Ctrl+N")
305
313
  self.tbAdd.setIconSize(QSize(iPx, iPx))
306
314
  self.tbAdd.setMenu(self.mAdd)
307
- self.tbAdd.setPopupMode(QToolButton.InstantPopup)
315
+ self.tbAdd.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
308
316
 
309
317
  # More Options Menu
310
318
  self.mMore = QMenu(self)
@@ -315,9 +323,6 @@ class GuiProjectToolBar(QWidget):
315
323
  self.aCollapse = self.mMore.addAction(self.tr("Collapse All"))
316
324
  self.aCollapse.triggered.connect(lambda: self.projTree.setExpandedFromHandle(None, False))
317
325
 
318
- self.aMoreUndo = self.mMore.addAction(self.tr("Undo Move"))
319
- self.aMoreUndo.triggered.connect(lambda: self.projTree.undoLastMove())
320
-
321
326
  self.aEmptyTrash = self.mMore.addAction(self.tr("Empty Trash"))
322
327
  self.aEmptyTrash.triggered.connect(lambda: self.projTree.emptyTrash())
323
328
 
@@ -325,7 +330,7 @@ class GuiProjectToolBar(QWidget):
325
330
  self.tbMore.setToolTip(self.tr("More Options"))
326
331
  self.tbMore.setIconSize(QSize(iPx, iPx))
327
332
  self.tbMore.setMenu(self.mMore)
328
- self.tbMore.setPopupMode(QToolButton.InstantPopup)
333
+ self.tbMore.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
329
334
 
330
335
  # Assemble
331
336
  self.outerBox = QHBoxLayout()
@@ -352,7 +357,7 @@ class GuiProjectToolBar(QWidget):
352
357
  def updateTheme(self) -> None:
353
358
  """Update theme elements."""
354
359
  qPalette = self.palette()
355
- qPalette.setBrush(QPalette.Window, qPalette.base())
360
+ qPalette.setBrush(QPalette.ColorRole.Window, qPalette.base())
356
361
  self.setPalette(qPalette)
357
362
 
358
363
  fadeCol = qPalette.text().color()
@@ -360,12 +365,13 @@ class GuiProjectToolBar(QWidget):
360
365
  "QToolButton {{padding: {0}px; border: none; background: transparent;}} "
361
366
  "QToolButton:hover {{border: none; background: rgba({1},{2},{3},0.2);}}"
362
367
  ).format(CONFIG.pxInt(2), fadeCol.red(), fadeCol.green(), fadeCol.blue())
368
+ buttonStyleMenu = f"{buttonStyle} QToolButton::menu-indicator {{image: none;}}"
363
369
 
364
- self.tbQuick.setStyleSheet(buttonStyle)
370
+ self.tbQuick.setStyleSheet(buttonStyleMenu)
365
371
  self.tbMoveU.setStyleSheet(buttonStyle)
366
372
  self.tbMoveD.setStyleSheet(buttonStyle)
367
- self.tbAdd.setStyleSheet(buttonStyle)
368
- self.tbMore.setStyleSheet(buttonStyle)
373
+ self.tbAdd.setStyleSheet(buttonStyleMenu)
374
+ self.tbMore.setStyleSheet(buttonStyleMenu)
369
375
 
370
376
  self.tbQuick.setIcon(SHARED.theme.getIcon("bookmark"))
371
377
  self.tbMoveU.setIcon(SHARED.theme.getIcon("up"))
@@ -429,7 +435,7 @@ class GuiProjectToolBar(QWidget):
429
435
  return
430
436
 
431
437
  ##
432
- # Slots
438
+ # Private Slots
433
439
  ##
434
440
 
435
441
  @pyqtSlot(str)
@@ -469,14 +475,14 @@ class GuiProjectTree(QTreeWidget):
469
475
 
470
476
  # Internal Variables
471
477
  self._treeMap = {}
472
- self._lastMove = {}
473
478
  self._timeChanged = 0.0
479
+ self._popAlert = None
474
480
 
475
481
  # Build GUI
476
482
  # =========
477
483
 
478
484
  # Context Menu
479
- self.setContextMenuPolicy(Qt.CustomContextMenu)
485
+ self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
480
486
  self.customContextMenuRequested.connect(self._openContextMenu)
481
487
 
482
488
  # Tree Settings
@@ -484,7 +490,7 @@ class GuiProjectTree(QTreeWidget):
484
490
  cMg = CONFIG.pxInt(6)
485
491
 
486
492
  self.setIconSize(QSize(iPx, iPx))
487
- self.setFrameStyle(QFrame.NoFrame)
493
+ self.setFrameStyle(QFrame.Shape.NoFrame)
488
494
  self.setUniformRowHeights(True)
489
495
  self.setAllColumnsShowFocus(True)
490
496
  self.setExpandsOnDoubleClick(False)
@@ -497,19 +503,18 @@ class GuiProjectTree(QTreeWidget):
497
503
  treeHeader = self.header()
498
504
  treeHeader.setStretchLastSection(False)
499
505
  treeHeader.setMinimumSectionSize(iPx + cMg)
500
- treeHeader.setSectionResizeMode(self.C_NAME, QHeaderView.Stretch)
501
- treeHeader.setSectionResizeMode(self.C_COUNT, QHeaderView.ResizeToContents)
502
- treeHeader.setSectionResizeMode(self.C_ACTIVE, QHeaderView.Fixed)
503
- 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)
504
510
  treeHeader.resizeSection(self.C_ACTIVE, iPx + cMg)
505
511
  treeHeader.resizeSection(self.C_STATUS, iPx + cMg)
506
512
 
507
513
  # Allow Move by Drag & Drop
508
514
  self.setDragEnabled(True)
509
- self.setDragDropMode(QAbstractItemView.InternalMove)
510
- self.setDropIndicatorShown(True)
515
+ self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
511
516
 
512
- # 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
513
518
  # releases (see #1561) and instead use our own implementation
514
519
  self.setAutoScroll(False)
515
520
 
@@ -517,21 +522,21 @@ class GuiProjectTree(QTreeWidget):
517
522
  # Due to a bug, this stops working somewhere between Qt 5.15.3
518
523
  # and 5.15.8, so this is also blocked in dropEvent (see #1569)
519
524
  trRoot = self.invisibleRootItem()
520
- trRoot.setFlags(trRoot.flags() ^ Qt.ItemIsDropEnabled)
525
+ trRoot.setFlags(trRoot.flags() ^ Qt.ItemFlag.ItemIsDropEnabled)
521
526
 
522
527
  # Cached values
523
528
  self._lblActive = self.tr("Active")
524
529
  self._lblInactive = self.tr("Inactive")
525
530
 
526
531
  # Set selection options
527
- self.setSelectionMode(QAbstractItemView.SingleSelection)
528
- self.setSelectionBehavior(QAbstractItemView.SelectRows)
532
+ self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
533
+ self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
529
534
 
530
535
  # Connect signals
531
536
  self.itemDoubleClicked.connect(self._treeDoubleClick)
532
537
  self.itemSelectionChanged.connect(self._treeSelectionChange)
533
538
 
534
- # Autoscroll
539
+ # Auto Scroll
535
540
  self._scrollMargin = SHARED.theme.baseIconSize
536
541
  self._scrollDirection = 0
537
542
  self._scrollTimer = QTimer()
@@ -549,13 +554,13 @@ class GuiProjectTree(QTreeWidget):
549
554
  """Set or update tree widget settings."""
550
555
  # Scroll bars
551
556
  if CONFIG.hideVScroll:
552
- self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
557
+ self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
553
558
  else:
554
- self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
559
+ self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
555
560
  if CONFIG.hideHScroll:
556
- self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
561
+ self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
557
562
  else:
558
- self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
563
+ self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
559
564
  return
560
565
 
561
566
  ##
@@ -566,10 +571,22 @@ class GuiProjectTree(QTreeWidget):
566
571
  """Clear the GUI content and the related map."""
567
572
  self.clear()
568
573
  self._treeMap = {}
569
- self._lastMove = {}
570
574
  self._timeChanged = 0.0
571
575
  return
572
576
 
577
+ def createNewNote(self, tag: str, itemClass: nwItemClass | None) -> bool:
578
+ """Create a new note. This function is used by the document
579
+ editor to create note files for unknown tags.
580
+ """
581
+ rHandle = SHARED.project.tree.findRoot(itemClass)
582
+ if rHandle:
583
+ tHandle = SHARED.project.newFile(tag, rHandle)
584
+ if tHandle:
585
+ SHARED.project.writeNewFile(tHandle, 1, False, f"@tag: {tag}\n\n")
586
+ self.revealNewTreeItem(tHandle, wordCount=True)
587
+ return True
588
+ return False
589
+
573
590
  def newTreeItem(self, itemType: nwItemType, itemClass: nwItemClass | None = None,
574
591
  hLevel: int = 1, isNote: bool = False) -> bool:
575
592
  """Add new item to the tree, with a given itemType (and
@@ -724,7 +741,6 @@ class GuiProjectTree(QTreeWidget):
724
741
 
725
742
  cItem = pItem.takeChild(tIndex)
726
743
  pItem.insertChild(nIndex, cItem)
727
- self._recordLastMove(cItem, pItem, tIndex)
728
744
 
729
745
  self._alertTreeChange(tHandle, flush=True)
730
746
  self.setCurrentItem(tItem)
@@ -753,19 +769,16 @@ class GuiProjectTree(QTreeWidget):
753
769
  self.setCurrentItem(tItem.child(0))
754
770
  return
755
771
 
756
- def renameTreeItem(self, tHandle: str) -> bool:
772
+ def renameTreeItem(self, tHandle: str, name: str = "") -> None:
757
773
  """Open a dialog to edit the label of an item."""
758
774
  tItem = SHARED.project.tree[tHandle]
759
- if tItem is None:
760
- return False
761
-
762
- newLabel, dlgOk = GuiEditLabel.getLabel(self, text=tItem.itemName)
763
- if dlgOk:
764
- tItem.setName(newLabel)
765
- self.setTreeItemValues(tHandle)
766
- self._alertTreeChange(tHandle, flush=False)
767
-
768
- 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
769
782
 
770
783
  def saveTreeOrder(self) -> None:
771
784
  """Build a list of the items in the project tree and send them
@@ -828,6 +841,7 @@ class GuiProjectTree(QTreeWidget):
828
841
 
829
842
  return status
830
843
 
844
+ @pyqtSlot()
831
845
  def emptyTrash(self) -> bool:
832
846
  """Permanently delete all documents in the Trash folder. This
833
847
  function only asks for confirmation once, and calls the regular
@@ -906,15 +920,13 @@ class GuiProjectTree(QTreeWidget):
906
920
  logger.info("Action cancelled by user")
907
921
  return False
908
922
 
909
- wCount = self._getItemWordCount(tHandle)
910
923
  self.propagateCount(tHandle, 0)
911
924
 
912
925
  tIndex = trItemP.indexOfChild(trItemS)
913
926
  trItemC = trItemP.takeChild(tIndex)
914
927
  trItemT.addChild(trItemC)
915
928
 
916
- self._postItemMove(tHandle, wCount)
917
- self._recordLastMove(trItemS, trItemP, tIndex)
929
+ self._postItemMove(tHandle)
918
930
  self._alertTreeChange(tHandle, flush=flush)
919
931
 
920
932
  logger.debug("Moved item '%s' to Trash", tHandle)
@@ -1075,46 +1087,6 @@ class GuiProjectTree(QTreeWidget):
1075
1087
  logger.info("%d item(s) added to the project tree", count)
1076
1088
  return
1077
1089
 
1078
- def undoLastMove(self) -> bool:
1079
- """Attempt to undo the last action."""
1080
- srcItem = self._lastMove.get("item", None)
1081
- dstItem = self._lastMove.get("parent", None)
1082
- dstIndex = self._lastMove.get("index", None)
1083
-
1084
- srcOK = isinstance(srcItem, QTreeWidgetItem)
1085
- dstOk = isinstance(dstItem, QTreeWidgetItem)
1086
- if not srcOK or not dstOk or dstIndex is None:
1087
- logger.debug("No tree move to undo")
1088
- return False
1089
-
1090
- if srcItem not in self._treeMap.values():
1091
- logger.warning("Source item no longer exists")
1092
- return False
1093
-
1094
- if dstItem not in self._treeMap.values():
1095
- logger.warning("Previous parent item no longer exists")
1096
- return False
1097
-
1098
- dstIndex = min(max(0, dstIndex), dstItem.childCount())
1099
- sHandle = srcItem.data(self.C_DATA, self.D_HANDLE)
1100
- dHandle = dstItem.data(self.C_DATA, self.D_HANDLE)
1101
- logger.debug("Moving item '%s' back to '%s', index %d", sHandle, dHandle, dstIndex)
1102
-
1103
- wCount = self._getItemWordCount(sHandle)
1104
- self.propagateCount(sHandle, 0)
1105
- parItem = srcItem.parent()
1106
- srcIndex = parItem.indexOfChild(srcItem)
1107
- movItem = parItem.takeChild(srcIndex)
1108
- dstItem.insertChild(dstIndex, movItem)
1109
-
1110
- self._postItemMove(sHandle, wCount)
1111
- self._alertTreeChange(sHandle, flush=True)
1112
-
1113
- self.setCurrentItem(movItem)
1114
- self._lastMove = {}
1115
-
1116
- return True
1117
-
1118
1090
  def getSelectedHandle(self) -> str | None:
1119
1091
  """Get the currently selected handle. If multiple items are
1120
1092
  selected, return the first.
@@ -1135,7 +1107,7 @@ class GuiProjectTree(QTreeWidget):
1135
1107
 
1136
1108
  selIndex = self.selectedIndexes()
1137
1109
  if selIndex and doScroll:
1138
- self.scrollTo(selIndex[0], QAbstractItemView.PositionAtCenter)
1110
+ self.scrollTo(selIndex[0], QAbstractItemView.ScrollHint.PositionAtCenter)
1139
1111
 
1140
1112
  return True
1141
1113
 
@@ -1170,6 +1142,15 @@ class GuiProjectTree(QTreeWidget):
1170
1142
  tHandle = self.getSelectedHandle()
1171
1143
  if tHandle is not None:
1172
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
+
1173
1154
  return
1174
1155
 
1175
1156
  @pyqtSlot("QTreeWidgetItem*", int)
@@ -1200,160 +1181,29 @@ class GuiProjectTree(QTreeWidget):
1200
1181
  tItem = None
1201
1182
  tHandle = None
1202
1183
  hasChild = False
1203
- selItem = self.itemAt(clickPos)
1204
- if isinstance(selItem, QTreeWidgetItem):
1205
- 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)
1206
1188
  tItem = SHARED.project.tree[tHandle]
1207
- hasChild = selItem.childCount() > 0
1189
+ hasChild = sItem.childCount() > 0
1208
1190
 
1209
1191
  if tItem is None or tHandle is None:
1210
1192
  logger.debug("No item found")
1211
1193
  return False
1212
1194
 
1213
- ctxMenu = QMenu(self)
1214
-
1215
- # Trash Folder
1216
- # ============
1217
-
1195
+ ctxMenu = _TreeContextMenu(self, tItem)
1218
1196
  trashHandle = SHARED.project.tree.trashRoot
1219
- if tItem.itemHandle == trashHandle and trashHandle is not None:
1220
- # The trash folder only has one option
1221
- aEmptyTrash = ctxMenu.addAction(self.tr("Empty Trash"))
1222
- aEmptyTrash.triggered.connect(lambda: self.emptyTrash())
1223
- ctxMenu.exec_(self.viewport().mapToGlobal(clickPos))
1224
- return True
1225
-
1226
- # Document Actions
1227
- # ================
1228
-
1229
- isRoot = tItem.isRootType()
1230
- isFolder = tItem.isFolderType()
1231
- isFile = tItem.isFileType()
1232
-
1233
- if isFile:
1234
- aOpenDoc = ctxMenu.addAction(self.tr("Open Document"))
1235
- aOpenDoc.triggered.connect(
1236
- lambda: self.projView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, "", True)
1237
- )
1238
- aViewDoc = ctxMenu.addAction(self.tr("View Document"))
1239
- aViewDoc.triggered.connect(
1240
- lambda: self.projView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, "", False)
1241
- )
1242
- ctxMenu.addSeparator()
1243
-
1244
- # Edit Item Settings
1245
- # ==================
1246
-
1247
- aLabel = ctxMenu.addAction(self.tr("Rename"))
1248
- aLabel.triggered.connect(lambda: self.renameTreeItem(tHandle))
1249
-
1250
- if isFile:
1251
- aActive = ctxMenu.addAction(self.tr("Toggle Active"))
1252
- aActive.triggered.connect(lambda: self._toggleItemActive(tHandle))
1253
-
1254
- checkMark = f" ({nwUnicode.U_CHECK})"
1255
- if tItem.isNovelLike():
1256
- mStatus = ctxMenu.addMenu(self.tr("Set Status to ..."))
1257
- for n, (key, entry) in enumerate(SHARED.project.data.itemStatus.items()):
1258
- entryName = entry["name"] + (checkMark if tItem.itemStatus == key else "")
1259
- aStatus = mStatus.addAction(entry["icon"], entryName)
1260
- aStatus.triggered.connect(
1261
- lambda n, key=key: self._changeItemStatus(tHandle, key)
1262
- )
1263
- mStatus.addSeparator()
1264
- aManage1 = mStatus.addAction("Manage Labels ...")
1265
- aManage1.triggered.connect(
1266
- lambda: self.projView.projectSettingsRequest.emit(GuiProjectSettings.TAB_STATUS)
1267
- )
1268
- else:
1269
- mImport = ctxMenu.addMenu(self.tr("Set Importance to ..."))
1270
- for n, (key, entry) in enumerate(SHARED.project.data.itemImport.items()):
1271
- entryName = entry["name"] + (checkMark if tItem.itemImport == key else "")
1272
- aImport = mImport.addAction(entry["icon"], entryName)
1273
- aImport.triggered.connect(
1274
- lambda n, key=key: self._changeItemImport(tHandle, key)
1275
- )
1276
- mImport.addSeparator()
1277
- aManage2 = mImport.addAction("Manage Labels ...")
1278
- aManage2.triggered.connect(
1279
- lambda: self.projView.projectSettingsRequest.emit(GuiProjectSettings.TAB_IMPORT)
1280
- )
1281
-
1282
- # Transform Item
1283
- # ==============
1284
-
1285
- if not isRoot:
1286
- mTrans = ctxMenu.addMenu(self.tr("Transform"))
1287
-
1288
- trDoc = trConst(nwLabels.LAYOUT_NAME[nwItemLayout.DOCUMENT])
1289
- trNote = trConst(nwLabels.LAYOUT_NAME[nwItemLayout.NOTE])
1290
-
1291
- isDocFile = isFile and tItem.isDocumentLayout()
1292
- isNoteFile = isFile and tItem.isNoteLayout()
1293
-
1294
- if isNoteFile and tItem.documentAllowed():
1295
- aConvert1 = mTrans.addAction(self.tr("Convert to {0}").format(trDoc))
1296
- aConvert1.triggered.connect(
1297
- lambda: self._changeItemLayout(tHandle, nwItemLayout.DOCUMENT)
1298
- )
1299
-
1300
- if isDocFile:
1301
- aConvert2 = mTrans.addAction(self.tr("Convert to {0}").format(trNote))
1302
- aConvert2.triggered.connect(
1303
- lambda: self._changeItemLayout(tHandle, nwItemLayout.NOTE)
1304
- )
1305
-
1306
- if isFolder and tItem.documentAllowed():
1307
- aConvert3 = mTrans.addAction(self.tr("Convert to {0}").format(trDoc))
1308
- aConvert3.triggered.connect(
1309
- lambda: self._covertFolderToFile(tHandle, nwItemLayout.DOCUMENT)
1310
- )
1311
-
1312
- if isFolder:
1313
- aConvert4 = mTrans.addAction(self.tr("Convert to {0}").format(trNote))
1314
- aConvert4.triggered.connect(
1315
- lambda: self._covertFolderToFile(tHandle, nwItemLayout.NOTE)
1316
- )
1317
-
1318
- if hasChild and isFile:
1319
- aMerge1 = mTrans.addAction(self.tr("Merge Child Items into Self"))
1320
- aMerge1.triggered.connect(lambda: self._mergeDocuments(tHandle, False))
1321
- aMerge2 = mTrans.addAction(self.tr("Merge Child Items into New"))
1322
- aMerge2.triggered.connect(lambda: self._mergeDocuments(tHandle, True))
1323
-
1324
- if hasChild and isFolder:
1325
- aMerge3 = mTrans.addAction(self.tr("Merge Documents in Folder"))
1326
- aMerge3.triggered.connect(lambda: self._mergeDocuments(tHandle, True))
1327
-
1328
- if isFile:
1329
- aSplit1 = mTrans.addAction(self.tr("Split Document by Headers"))
1330
- aSplit1.triggered.connect(lambda: self._splitDocument(tHandle))
1331
-
1332
- # Expand/Collapse/Delete/Duplicate
1333
- # ================================
1334
-
1335
- ctxMenu.addSeparator()
1336
-
1337
- if hasChild:
1338
- aExpand = ctxMenu.addAction(self.tr("Expand All"))
1339
- aExpand.triggered.connect(lambda: self.setExpandedFromHandle(tHandle, True))
1340
- aCollapse = ctxMenu.addAction(self.tr("Collapse All"))
1341
- aCollapse.triggered.connect(lambda: self.setExpandedFromHandle(tHandle, False))
1342
- aDuplicate = ctxMenu.addAction(self.tr("Duplicate from Here"))
1343
- aDuplicate.triggered.connect(lambda: self._duplicateFromHandle(tHandle))
1344
- elif isFile:
1345
- aDuplicate = ctxMenu.addAction(self.tr("Duplicate Document"))
1346
- aDuplicate.triggered.connect(lambda: self._duplicateFromHandle(tHandle))
1347
-
1348
- if tItem.itemClass == nwItemClass.TRASH or isRoot or (isFolder and not hasChild):
1349
- aDelete = ctxMenu.addAction(self.tr("Delete Permanently"))
1350
- 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)
1351
1202
  else:
1352
- aMoveTrash = ctxMenu.addAction(self.tr("Move to Trash"))
1353
- aMoveTrash.triggered.connect(lambda: self.moveItemToTrash(tHandle))
1203
+ ctxMenu.buildSingleSelectMenu(hasChild)
1354
1204
 
1355
- # Show Context Menu
1356
1205
  ctxMenu.exec_(self.viewport().mapToGlobal(clickPos))
1206
+ ctxMenu.deleteLater()
1357
1207
 
1358
1208
  return True
1359
1209
 
@@ -1378,29 +1228,45 @@ class GuiProjectTree(QTreeWidget):
1378
1228
  for viewing if the user middle-clicked.
1379
1229
  """
1380
1230
  super().mousePressEvent(event)
1381
-
1382
- if event.button() == Qt.LeftButton:
1231
+ if event.button() == Qt.MouseButton.LeftButton:
1383
1232
  selItem = self.indexAt(event.pos())
1384
1233
  if not selItem.isValid():
1385
1234
  self.clearSelection()
1386
-
1387
- elif event.button() == Qt.MiddleButton:
1235
+ elif event.button() == Qt.MouseButton.MiddleButton:
1388
1236
  selItem = self.itemAt(event.pos())
1389
- if not isinstance(selItem, QTreeWidgetItem):
1390
- return
1391
-
1392
- tHandle = selItem.data(self.C_DATA, self.D_HANDLE)
1393
- tItem = SHARED.project.tree[tHandle]
1394
- if tItem is None:
1395
- 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
1396
1242
 
1397
- if tItem.isFileType():
1398
- 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
1399
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()
1400
1266
  return
1401
1267
 
1402
1268
  def dragMoveEvent(self, event: QDragMoveEvent) -> None:
1403
- """Capture the drag move event to enable edge autoscroll."""
1269
+ """Capture the drag move event to enable edge auto scroll."""
1404
1270
  y = event.pos().y()
1405
1271
  if y < self._scrollMargin:
1406
1272
  if not self._scrollTimer.isActive():
@@ -1414,38 +1280,33 @@ class GuiProjectTree(QTreeWidget):
1414
1280
  return
1415
1281
 
1416
1282
  def dropEvent(self, event: QDropEvent) -> None:
1417
- """Overload the drop item event to ensure relevant data has been
1418
- updated.
1283
+ """Overload the drop item event to ensure the drag and drop
1284
+ action is allowed, and update relevant data.
1419
1285
  """
1420
- sHandle = self.getSelectedHandle()
1421
- sItem = self._getTreeItem(sHandle) if sHandle else None
1422
- if sHandle is None or sItem is None or sItem.parent() is None:
1423
- logger.error("Invalid drag and drop event")
1424
- event.ignore()
1425
- return
1426
-
1427
- if not self.indexAt(event.pos()).isValid():
1428
- # Needed due to a bug somewhere around Qt 5.15.8 that
1429
- # 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:
1430
1290
  logger.error("Invalid drop location")
1431
1291
  event.ignore()
1432
1292
  return
1433
1293
 
1434
- logger.debug("Drag'n'drop of item '%s' accepted", sHandle)
1435
-
1436
- isExpanded = sItem.isExpanded()
1437
- pItem = sItem.parent()
1438
- pIndex = pItem.indexOfChild(sItem) if pItem else 0
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)
1439
1301
 
1440
- wCount = self._getItemWordCount(sHandle)
1441
- self.propagateCount(sHandle, 0)
1302
+ super().dropEvent(event)
1442
1303
 
1443
- super().dropEvent(event)
1444
- self._postItemMove(sHandle, wCount)
1445
- self._recordLastMove(sItem, pItem, pIndex)
1446
- self._alertTreeChange(sHandle, flush=True)
1304
+ for mHandle, (sItem, isExpanded) in mItems.items():
1305
+ self._postItemMove(mHandle)
1306
+ sItem.setExpanded(isExpanded)
1307
+ self._alertTreeChange(mHandle, flush=False)
1447
1308
 
1448
- sItem.setExpanded(isExpanded)
1309
+ self.saveTreeOrder()
1449
1310
 
1450
1311
  return
1451
1312
 
@@ -1453,17 +1314,16 @@ class GuiProjectTree(QTreeWidget):
1453
1314
  # Internal Functions
1454
1315
  ##
1455
1316
 
1456
- def _postItemMove(self, tHandle: str, wCount: int) -> bool:
1317
+ def _postItemMove(self, tHandle: str) -> None:
1457
1318
  """Run various maintenance tasks for a moved item."""
1458
1319
  trItemS = self._getTreeItem(tHandle)
1459
1320
  nwItemS = SHARED.project.tree[tHandle]
1460
1321
  trItemP = trItemS.parent() if trItemS else None
1461
1322
  if trItemP is None or nwItemS is None:
1462
1323
  logger.error("Failed to find new parent item of '%s'", tHandle)
1463
- return False
1324
+ return
1464
1325
 
1465
- # Update item parent handle in the project, make sure meta data
1466
- # is updated accordingly, and update word count
1326
+ # Update item parent handle in the project
1467
1327
  pHandle = trItemP.data(self.C_DATA, self.D_HANDLE)
1468
1328
  nwItemS.setParent(pHandle)
1469
1329
  trItemP.setExpanded(True)
@@ -1474,19 +1334,16 @@ class GuiProjectTree(QTreeWidget):
1474
1334
  for mHandle in mHandles:
1475
1335
  logger.debug("Updating item '%s'", mHandle)
1476
1336
  SHARED.project.tree.updateItemData(mHandle)
1477
-
1478
- # Update the index
1479
1337
  if nwItemS.isInactiveClass():
1480
1338
  SHARED.project.index.deleteHandle(mHandle)
1481
1339
  else:
1482
1340
  SHARED.project.index.reIndexHandle(mHandle)
1483
-
1484
1341
  self.setTreeItemValues(mHandle)
1485
1342
 
1486
- # Trigger dependent updates
1487
- self.propagateCount(tHandle, wCount)
1343
+ # Update word count
1344
+ self.propagateCount(tHandle, nwItemS.wordCount, countChildren=True)
1488
1345
 
1489
- return True
1346
+ return
1490
1347
 
1491
1348
  def _getItemWordCount(self, tHandle: str) -> int:
1492
1349
  """Return the word count of a given item handle."""
@@ -1497,15 +1354,6 @@ class GuiProjectTree(QTreeWidget):
1497
1354
  """Return the QTreeWidgetItem of a given item handle."""
1498
1355
  return self._treeMap.get(tHandle, None) if tHandle else None
1499
1356
 
1500
- def _toggleItemActive(self, tHandle: str) -> None:
1501
- """Toggle the active status of an item."""
1502
- tItem = SHARED.project.tree[tHandle]
1503
- if tItem is not None:
1504
- tItem.setActive(not tItem.isActive)
1505
- self.setTreeItemValues(tItem.itemHandle)
1506
- self._alertTreeChange(tHandle, flush=False)
1507
- return
1508
-
1509
1357
  def _recursiveSetExpanded(self, trItem: QTreeWidgetItem, isExpanded: bool) -> None:
1510
1358
  """Recursive function to set expanded status starting from (and
1511
1359
  not including) a given item.
@@ -1518,60 +1366,6 @@ class GuiProjectTree(QTreeWidget):
1518
1366
  self._recursiveSetExpanded(chItem, isExpanded)
1519
1367
  return
1520
1368
 
1521
- def _changeItemStatus(self, tHandle: str, tStatus: str) -> None:
1522
- """Set a new status value of an item."""
1523
- tItem = SHARED.project.tree[tHandle]
1524
- if tItem is not None:
1525
- tItem.setStatus(tStatus)
1526
- self.setTreeItemValues(tItem.itemHandle)
1527
- self._alertTreeChange(tHandle, flush=False)
1528
- return
1529
-
1530
- def _changeItemImport(self, tHandle: str, tImport: str) -> None:
1531
- """Set a new importance value of an item."""
1532
- tItem = SHARED.project.tree[tHandle]
1533
- if tItem is not None:
1534
- tItem.setImport(tImport)
1535
- self.setTreeItemValues(tItem.itemHandle)
1536
- self._alertTreeChange(tHandle, flush=False)
1537
- return
1538
-
1539
- def _changeItemLayout(self, tHandle: str, itemLayout: nwItemLayout) -> None:
1540
- """Set a new item layout value of an item."""
1541
- tItem = SHARED.project.tree[tHandle]
1542
- if tItem is not None:
1543
- if itemLayout == nwItemLayout.DOCUMENT and tItem.documentAllowed():
1544
- tItem.setLayout(nwItemLayout.DOCUMENT)
1545
- self.setTreeItemValues(tHandle)
1546
- self._alertTreeChange(tHandle, flush=False)
1547
- elif itemLayout == nwItemLayout.NOTE:
1548
- tItem.setLayout(nwItemLayout.NOTE)
1549
- self.setTreeItemValues(tHandle)
1550
- self._alertTreeChange(tHandle, flush=False)
1551
- return
1552
-
1553
- def _covertFolderToFile(self, tHandle: str, itemLayout: nwItemLayout) -> None:
1554
- """Convert a folder to a note or document."""
1555
- tItem = SHARED.project.tree[tHandle]
1556
- if tItem is not None and tItem.isFolderType():
1557
- msgYes = SHARED.question(self.tr(
1558
- "Do you want to convert the folder to a {0}? "
1559
- "This action cannot be reversed."
1560
- ).format(trConst(nwLabels.LAYOUT_NAME[itemLayout])))
1561
- if msgYes and itemLayout == nwItemLayout.DOCUMENT and tItem.documentAllowed():
1562
- tItem.setType(nwItemType.FILE)
1563
- tItem.setLayout(nwItemLayout.DOCUMENT)
1564
- self.setTreeItemValues(tHandle)
1565
- self._alertTreeChange(tHandle, flush=False)
1566
- elif msgYes and itemLayout == nwItemLayout.NOTE:
1567
- tItem.setType(nwItemType.FILE)
1568
- tItem.setLayout(nwItemLayout.NOTE)
1569
- self.setTreeItemValues(tHandle)
1570
- self._alertTreeChange(tHandle, flush=False)
1571
- else:
1572
- logger.info("Folder conversion cancelled")
1573
- return
1574
-
1575
1369
  def _mergeDocuments(self, tHandle: str, newFile: bool) -> bool:
1576
1370
  """Merge an item's child documents into a single document."""
1577
1371
  logger.info("Request to merge items under handle '%s'", tHandle)
@@ -1591,7 +1385,7 @@ class GuiProjectTree(QTreeWidget):
1591
1385
  dlgMerge = GuiDocMerge(self.mainGui, tHandle, itemList)
1592
1386
  dlgMerge.exec_()
1593
1387
 
1594
- if dlgMerge.result() == QDialog.Accepted:
1388
+ if dlgMerge.result() == QDialog.DialogCode.Accepted:
1595
1389
 
1596
1390
  mrgData = dlgMerge.getData()
1597
1391
  mrgList = mrgData.get("finalItems", [])
@@ -1661,7 +1455,7 @@ class GuiProjectTree(QTreeWidget):
1661
1455
  dlgSplit = GuiDocSplit(self.mainGui, tHandle)
1662
1456
  dlgSplit.exec_()
1663
1457
 
1664
- if dlgSplit.result() == QDialog.Accepted:
1458
+ if dlgSplit.result() == QDialog.DialogCode.Accepted:
1665
1459
 
1666
1460
  splitData, splitText = dlgSplit.getData()
1667
1461
 
@@ -1766,10 +1560,10 @@ class GuiProjectTree(QTreeWidget):
1766
1560
  newItem.setText(self.C_ACTIVE, "")
1767
1561
  newItem.setText(self.C_STATUS, "")
1768
1562
 
1769
- newItem.setTextAlignment(self.C_NAME, Qt.AlignLeft)
1770
- newItem.setTextAlignment(self.C_COUNT, Qt.AlignRight)
1771
- newItem.setTextAlignment(self.C_ACTIVE, Qt.AlignLeft)
1772
- 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)
1773
1567
 
1774
1568
  newItem.setData(self.C_DATA, self.D_HANDLE, tHandle)
1775
1569
  newItem.setData(self.C_DATA, self.D_WORDS, 0)
@@ -1837,16 +1631,322 @@ class GuiProjectTree(QTreeWidget):
1837
1631
 
1838
1632
  return
1839
1633
 
1840
- def _recordLastMove(self, srcItem: QTreeWidgetItem,
1841
- parItem: QTreeWidgetItem, parIndex: int) -> None:
1842
- """Record the last action so that it can be undone."""
1843
- prevItem = self._lastMove.get("item", None)
1844
- if prevItem is None or srcItem != prevItem:
1845
- self._lastMove = {
1846
- "item": srcItem,
1847
- "parent": parItem,
1848
- "index": parIndex,
1849
- }
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
+
1850
1651
  return
1851
1652
 
1852
- # 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