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