novelWriter 2.6b1__py3-none-any.whl → 2.6b2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {novelWriter-2.6b1.dist-info → novelWriter-2.6b2.dist-info}/METADATA +3 -3
- {novelWriter-2.6b1.dist-info → novelWriter-2.6b2.dist-info}/RECORD +68 -52
- {novelWriter-2.6b1.dist-info → novelWriter-2.6b2.dist-info}/WHEEL +1 -1
- novelwriter/__init__.py +49 -10
- novelwriter/assets/i18n/nw_de_DE.qm +0 -0
- novelwriter/assets/i18n/nw_pt_BR.qm +0 -0
- novelwriter/assets/i18n/nw_ru_RU.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 +37 -2
- novelwriter/config.py +15 -12
- novelwriter/constants.py +24 -9
- novelwriter/core/coretools.py +111 -125
- novelwriter/core/docbuild.py +3 -2
- novelwriter/core/index.py +9 -19
- novelwriter/core/item.py +39 -6
- novelwriter/core/itemmodel.py +518 -0
- novelwriter/core/project.py +67 -89
- novelwriter/core/status.py +7 -5
- novelwriter/core/tree.py +268 -287
- novelwriter/dialogs/docmerge.py +7 -17
- novelwriter/dialogs/preferences.py +3 -3
- novelwriter/dialogs/projectsettings.py +2 -2
- novelwriter/enum.py +7 -0
- novelwriter/extensions/configlayout.py +6 -4
- novelwriter/formats/todocx.py +34 -38
- novelwriter/formats/tohtml.py +14 -15
- novelwriter/formats/tokenizer.py +21 -17
- novelwriter/formats/toodt.py +53 -124
- novelwriter/formats/toqdoc.py +92 -44
- novelwriter/gui/doceditor.py +230 -219
- novelwriter/gui/docviewer.py +38 -9
- novelwriter/gui/docviewerpanel.py +14 -22
- novelwriter/gui/itemdetails.py +17 -24
- novelwriter/gui/mainmenu.py +13 -8
- novelwriter/gui/noveltree.py +12 -12
- novelwriter/gui/outline.py +10 -11
- novelwriter/gui/projtree.py +548 -1202
- novelwriter/gui/search.py +9 -10
- novelwriter/gui/theme.py +7 -3
- novelwriter/guimain.py +59 -43
- novelwriter/shared.py +52 -23
- novelwriter/text/patterns.py +17 -5
- novelwriter/tools/manusbuild.py +13 -11
- novelwriter/tools/manussettings.py +42 -52
- novelwriter/types.py +7 -1
- {novelWriter-2.6b1.dist-info → novelWriter-2.6b2.dist-info}/LICENSE.md +0 -0
- {novelWriter-2.6b1.dist-info → novelWriter-2.6b2.dist-info}/entry_points.txt +0 -0
- {novelWriter-2.6b1.dist-info → novelWriter-2.6b2.dist-info}/top_level.txt +0 -0
novelwriter/core/item.py
CHANGED
@@ -27,8 +27,9 @@ import logging
|
|
27
27
|
|
28
28
|
from typing import TYPE_CHECKING, Any
|
29
29
|
|
30
|
-
from PyQt5.QtGui import QIcon
|
30
|
+
from PyQt5.QtGui import QFont, QIcon
|
31
31
|
|
32
|
+
from novelwriter import CONFIG, SHARED
|
32
33
|
from novelwriter.common import (
|
33
34
|
checkInt, isHandle, isItemClass, isItemLayout, isItemType, simplified,
|
34
35
|
yesNo
|
@@ -256,6 +257,8 @@ class NWItem:
|
|
256
257
|
self._paraCount = 0
|
257
258
|
self._cursorPos = 0
|
258
259
|
|
260
|
+
self._initCount = self._wordCount
|
261
|
+
|
259
262
|
return True
|
260
263
|
|
261
264
|
@classmethod
|
@@ -281,6 +284,15 @@ class NWItem:
|
|
281
284
|
cls._initCount = source._initCount
|
282
285
|
return cls
|
283
286
|
|
287
|
+
##
|
288
|
+
# Action Methods
|
289
|
+
##
|
290
|
+
|
291
|
+
def notifyToRefresh(self) -> None:
|
292
|
+
"""Notify GUI that item info needs to be refreshed."""
|
293
|
+
self._project.tree.refreshItems([self._handle])
|
294
|
+
return
|
295
|
+
|
284
296
|
##
|
285
297
|
# Lookup Methods
|
286
298
|
##
|
@@ -309,6 +321,19 @@ class NWItem:
|
|
309
321
|
|
310
322
|
return trConst(nwLabels.ITEM_DESCRIPTION.get(descKey, ""))
|
311
323
|
|
324
|
+
def getMainIcon(self) -> QIcon:
|
325
|
+
"""Get the main item icon."""
|
326
|
+
return SHARED.theme.getItemIcon(self._type, self._class, self._layout, self._heading)
|
327
|
+
|
328
|
+
def getMainFont(self) -> QFont:
|
329
|
+
"""Get the main item icon."""
|
330
|
+
if CONFIG.emphLabels and self._layout == nwItemLayout.DOCUMENT:
|
331
|
+
if self._heading == "H1":
|
332
|
+
return SHARED.theme.guiFontBU
|
333
|
+
elif self._heading == "H2":
|
334
|
+
return SHARED.theme.guiFontB
|
335
|
+
return SHARED.theme.guiFont
|
336
|
+
|
312
337
|
def getImportStatus(self) -> tuple[str, QIcon]:
|
313
338
|
"""Return the relevant importance or status label and icon for
|
314
339
|
the current item based on its class.
|
@@ -319,6 +344,19 @@ class NWItem:
|
|
319
344
|
entry = self._project.data.itemImport[self._import]
|
320
345
|
return entry.name, entry.icon
|
321
346
|
|
347
|
+
def getActiveStatus(self) -> tuple[str, QIcon]:
|
348
|
+
"""Return the relevant active status label and icon for
|
349
|
+
the current item based on its type.
|
350
|
+
"""
|
351
|
+
if self.isFileType():
|
352
|
+
key = "checked" if self._active else "unchecked"
|
353
|
+
text = trConst(nwLabels.ACTIVE_NAME[key])
|
354
|
+
icon = SHARED.theme.getIcon(key)
|
355
|
+
else:
|
356
|
+
text = ""
|
357
|
+
icon = SHARED.theme.getIcon("noncheckable")
|
358
|
+
return text, icon
|
359
|
+
|
322
360
|
##
|
323
361
|
# Checker Methods
|
324
362
|
##
|
@@ -553,8 +591,3 @@ class NWItem:
|
|
553
591
|
else:
|
554
592
|
self._cursorPos = 0
|
555
593
|
return
|
556
|
-
|
557
|
-
def saveInitialCount(self) -> None:
|
558
|
-
"""Save the initial word count."""
|
559
|
-
self._initCount = self._wordCount
|
560
|
-
return
|
@@ -0,0 +1,518 @@
|
|
1
|
+
"""
|
2
|
+
novelWriter – Project Item Model
|
3
|
+
================================
|
4
|
+
|
5
|
+
File History:
|
6
|
+
Created: 2024-11-16 [2.6b2] ProjectNode
|
7
|
+
Created: 2024-11-16 [2.6b2] ProjectModel
|
8
|
+
|
9
|
+
This file is a part of novelWriter
|
10
|
+
Copyright 2018–2024, Veronica Berglyd Olsen
|
11
|
+
|
12
|
+
This program is free software: you can redistribute it and/or modify
|
13
|
+
it under the terms of the GNU General Public License as published by
|
14
|
+
the Free Software Foundation, either version 3 of the License, or
|
15
|
+
(at your option) any later version.
|
16
|
+
|
17
|
+
This program is distributed in the hope that it will be useful, but
|
18
|
+
WITHOUT ANY WARRANTY; without even the implied warranty of
|
19
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
20
|
+
General Public License for more details.
|
21
|
+
|
22
|
+
You should have received a copy of the GNU General Public License
|
23
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
24
|
+
"""
|
25
|
+
from __future__ import annotations
|
26
|
+
|
27
|
+
import logging
|
28
|
+
|
29
|
+
from typing import TYPE_CHECKING
|
30
|
+
|
31
|
+
from PyQt5.QtCore import QAbstractItemModel, QMimeData, QModelIndex, Qt
|
32
|
+
from PyQt5.QtGui import QFont, QIcon
|
33
|
+
|
34
|
+
from novelwriter.common import decodeMimeHandles, encodeMimeHandles, minmax
|
35
|
+
from novelwriter.constants import nwConst
|
36
|
+
from novelwriter.core.item import NWItem
|
37
|
+
from novelwriter.enum import nwItemClass
|
38
|
+
from novelwriter.types import QtAlignRight
|
39
|
+
|
40
|
+
if TYPE_CHECKING: # pragma: no cover
|
41
|
+
from novelwriter.core.tree import NWTree
|
42
|
+
|
43
|
+
logger = logging.getLogger(__name__)
|
44
|
+
|
45
|
+
INV_ROOT = "invisibleRoot"
|
46
|
+
C_FACTOR = 0x0100
|
47
|
+
|
48
|
+
C_LABEL_TEXT = 0x0000 | Qt.ItemDataRole.DisplayRole
|
49
|
+
C_LABEL_ICON = 0x0000 | Qt.ItemDataRole.DecorationRole
|
50
|
+
C_LABEL_FONT = 0x0000 | Qt.ItemDataRole.FontRole
|
51
|
+
C_COUNT_TEXT = 0x0100 | Qt.ItemDataRole.DisplayRole
|
52
|
+
C_COUNT_ICON = 0x0100 | Qt.ItemDataRole.DecorationRole
|
53
|
+
C_COUNT_ALIGN = 0x0100 | Qt.ItemDataRole.TextAlignmentRole
|
54
|
+
C_ACTIVE_ICON = 0x0200 | Qt.ItemDataRole.DecorationRole
|
55
|
+
C_ACTIVE_TIP = 0x0200 | Qt.ItemDataRole.ToolTipRole
|
56
|
+
C_STATUS_ICON = 0x0300 | Qt.ItemDataRole.DecorationRole
|
57
|
+
C_STATUS_TIP = 0x0300 | Qt.ItemDataRole.ToolTipRole
|
58
|
+
|
59
|
+
NODE_FLAGS = Qt.ItemFlag.ItemIsEnabled
|
60
|
+
NODE_FLAGS |= Qt.ItemFlag.ItemIsSelectable
|
61
|
+
NODE_FLAGS |= Qt.ItemFlag.ItemIsDropEnabled
|
62
|
+
|
63
|
+
if TYPE_CHECKING: # pragma: no cover
|
64
|
+
# Requires Python 3.10
|
65
|
+
T_NodeData = str | QIcon | QFont | Qt.AlignmentFlag | None
|
66
|
+
|
67
|
+
|
68
|
+
class ProjectNode:
|
69
|
+
"""Core: Project Model Node Class
|
70
|
+
|
71
|
+
The project tree structure is saved as nodes in a tree, starting
|
72
|
+
from a root node. This class makes up these nodes.
|
73
|
+
|
74
|
+
Each node is a wrapper around an NWItem object. The NWItem is the
|
75
|
+
object representing a single item in the project, and it only
|
76
|
+
contains a reference to its parent as well as it top level root, but
|
77
|
+
is itself not structured in a hierarchy in memory.
|
78
|
+
|
79
|
+
This class provides the necessary hierarchical structure, as well as
|
80
|
+
the data entries needed for populating the GUI project tree. It also
|
81
|
+
handles pushing and pulling information from its NWItem when
|
82
|
+
necessary.
|
83
|
+
|
84
|
+
The data to be displayed could in principle be pulled from the
|
85
|
+
NWItem whenever it is needed, but for performance reason it is
|
86
|
+
cached, as the GUI will pull this information often.
|
87
|
+
"""
|
88
|
+
|
89
|
+
C_NAME = 0
|
90
|
+
C_COUNT = 1
|
91
|
+
C_ACTIVE = 2
|
92
|
+
C_STATUS = 3
|
93
|
+
|
94
|
+
__slots__ = ("_item", "_children", "_parent", "_row", "_cache", "_flags", "_count")
|
95
|
+
|
96
|
+
def __init__(self, item: NWItem) -> None:
|
97
|
+
self._item = item
|
98
|
+
self._children: list[ProjectNode] = []
|
99
|
+
self._parent: ProjectNode | None = None
|
100
|
+
self._row = 0
|
101
|
+
self._cache: dict[int, T_NodeData] = {}
|
102
|
+
self._flags = NODE_FLAGS
|
103
|
+
self._count = 0
|
104
|
+
self.refresh()
|
105
|
+
self.updateCount()
|
106
|
+
return
|
107
|
+
|
108
|
+
def __repr__(self) -> str:
|
109
|
+
return (
|
110
|
+
f"<ProjectNode handle={self._item.itemHandle} "
|
111
|
+
f"parent={self._parent.item.itemHandle if self._parent else None} "
|
112
|
+
f"row={self._row} "
|
113
|
+
f"children={len(self._children)}>"
|
114
|
+
)
|
115
|
+
|
116
|
+
def __bool__(self) -> bool:
|
117
|
+
"""A node should always evaluate to True."""
|
118
|
+
return True
|
119
|
+
|
120
|
+
##
|
121
|
+
# Properties
|
122
|
+
##
|
123
|
+
|
124
|
+
@property
|
125
|
+
def item(self) -> NWItem:
|
126
|
+
"""The project item of the node."""
|
127
|
+
return self._item
|
128
|
+
|
129
|
+
@property
|
130
|
+
def children(self) -> list[ProjectNode]:
|
131
|
+
"""All children of the node."""
|
132
|
+
return self._children
|
133
|
+
|
134
|
+
@property
|
135
|
+
def count(self) -> int:
|
136
|
+
"""The count of the node."""
|
137
|
+
return self._count
|
138
|
+
|
139
|
+
##
|
140
|
+
# Data Maintenance
|
141
|
+
##
|
142
|
+
|
143
|
+
def refresh(self) -> None:
|
144
|
+
"""Refresh data values."""
|
145
|
+
# Label
|
146
|
+
self._cache[C_LABEL_ICON] = self._item.getMainIcon()
|
147
|
+
self._cache[C_LABEL_TEXT] = self._item.itemName
|
148
|
+
self._cache[C_LABEL_FONT] = self._item.getMainFont()
|
149
|
+
|
150
|
+
# Count
|
151
|
+
self._cache[C_COUNT_ALIGN] = QtAlignRight
|
152
|
+
|
153
|
+
# Active
|
154
|
+
aText, aIcon = self._item.getActiveStatus()
|
155
|
+
self._cache[C_ACTIVE_TIP] = aText
|
156
|
+
self._cache[C_ACTIVE_ICON] = aIcon
|
157
|
+
|
158
|
+
# Status
|
159
|
+
sText, sIcon = self._item.getImportStatus()
|
160
|
+
self._cache[C_STATUS_TIP] = sText
|
161
|
+
self._cache[C_STATUS_ICON] = sIcon
|
162
|
+
|
163
|
+
return
|
164
|
+
|
165
|
+
def updateCount(self, propagate: bool = True) -> None:
|
166
|
+
"""Update counts, and propagate upwards in the tree."""
|
167
|
+
self._count = self._item.wordCount + sum(c._count for c in self._children)
|
168
|
+
self._cache[C_COUNT_TEXT] = f"{self._count:n}"
|
169
|
+
if propagate and (parent := self._parent):
|
170
|
+
parent.updateCount()
|
171
|
+
return
|
172
|
+
|
173
|
+
##
|
174
|
+
# Data Access
|
175
|
+
##
|
176
|
+
|
177
|
+
def row(self) -> int:
|
178
|
+
"""Return the node's row number."""
|
179
|
+
return self._row
|
180
|
+
|
181
|
+
def childCount(self) -> int:
|
182
|
+
"""Return the number of children of the node."""
|
183
|
+
return len(self._children)
|
184
|
+
|
185
|
+
def data(self, column: int, role: Qt.ItemDataRole) -> T_NodeData:
|
186
|
+
"""Return cached node data."""
|
187
|
+
return self._cache.get(C_FACTOR*column | role)
|
188
|
+
|
189
|
+
def flags(self) -> Qt.ItemFlag:
|
190
|
+
"""Return cached node flags."""
|
191
|
+
return self._flags
|
192
|
+
|
193
|
+
def parent(self) -> ProjectNode | None:
|
194
|
+
"""Return the parent of the node."""
|
195
|
+
return self._parent
|
196
|
+
|
197
|
+
def child(self, row: int) -> ProjectNode | None:
|
198
|
+
"""Return a child ofg the node."""
|
199
|
+
if 0 <= row < len(self._children):
|
200
|
+
return self._children[row]
|
201
|
+
return None
|
202
|
+
|
203
|
+
def allChildren(self) -> list[ProjectNode]:
|
204
|
+
"""Return a recursive list of all children."""
|
205
|
+
nodes: list[ProjectNode] = []
|
206
|
+
self._recursiveAppendChildren(nodes)
|
207
|
+
return nodes
|
208
|
+
|
209
|
+
##
|
210
|
+
# Data Edit
|
211
|
+
##
|
212
|
+
|
213
|
+
def addChild(self, child: ProjectNode, pos: int = -1) -> None:
|
214
|
+
"""Add a child item to this item."""
|
215
|
+
child._parent = self
|
216
|
+
self._updateRelationships(child)
|
217
|
+
if 0 <= pos < len(self._children):
|
218
|
+
self._children.insert(pos, child)
|
219
|
+
else:
|
220
|
+
child._row = len(self._children)
|
221
|
+
self._children.append(child)
|
222
|
+
self._refreshChildrenPos()
|
223
|
+
return
|
224
|
+
|
225
|
+
def takeChild(self, pos: int) -> ProjectNode | None:
|
226
|
+
"""Remove a child item and return it."""
|
227
|
+
if 0 <= pos < len(self._children):
|
228
|
+
node = self._children.pop(pos)
|
229
|
+
self._refreshChildrenPos()
|
230
|
+
self.updateCount()
|
231
|
+
return node
|
232
|
+
return None
|
233
|
+
|
234
|
+
def moveChild(self, source: int, target: int) -> None:
|
235
|
+
"""Move a child internally."""
|
236
|
+
count = len(self._children)
|
237
|
+
if (source != target) and (0 <= source < count) and (0 <= target <= count):
|
238
|
+
node = self._children.pop(source)
|
239
|
+
self._children.insert(target, node)
|
240
|
+
self._refreshChildrenPos()
|
241
|
+
return
|
242
|
+
|
243
|
+
def setExpanded(self, state: bool) -> None:
|
244
|
+
"""Set the node's expanded state."""
|
245
|
+
if state and self._children:
|
246
|
+
self._item.setExpanded(True)
|
247
|
+
else:
|
248
|
+
self._item.setExpanded(False)
|
249
|
+
return
|
250
|
+
|
251
|
+
##
|
252
|
+
# Internal Functions
|
253
|
+
##
|
254
|
+
|
255
|
+
def _recursiveAppendChildren(self, children: list[ProjectNode]) -> None:
|
256
|
+
"""Recursively add all nodes to a list."""
|
257
|
+
for node in self._children:
|
258
|
+
children.append(node)
|
259
|
+
node._recursiveAppendChildren(children)
|
260
|
+
return
|
261
|
+
|
262
|
+
def _refreshChildrenPos(self) -> None:
|
263
|
+
"""Update the row value on all children."""
|
264
|
+
for n, child in enumerate(self._children):
|
265
|
+
child._row = n
|
266
|
+
child.item.setOrder(n)
|
267
|
+
return
|
268
|
+
|
269
|
+
def _updateRelationships(self, child: ProjectNode) -> None:
|
270
|
+
"""Update a child item's relationships."""
|
271
|
+
if self._parent:
|
272
|
+
child.item.setParent(self._item.itemHandle)
|
273
|
+
child.item.setRoot(self._item.itemRoot)
|
274
|
+
child.item.setClassDefaults(self._item.itemClass)
|
275
|
+
child._flags = NODE_FLAGS | Qt.ItemFlag.ItemIsDragEnabled
|
276
|
+
else:
|
277
|
+
child.item.setParent(None)
|
278
|
+
child.item.setRoot(child.item.itemHandle)
|
279
|
+
child.item.setClassDefaults(child.item.itemClass)
|
280
|
+
return
|
281
|
+
|
282
|
+
|
283
|
+
class ProjectModel(QAbstractItemModel):
|
284
|
+
"""Core: Project Model Class
|
285
|
+
|
286
|
+
This class provides the interface for the tree widget used on the
|
287
|
+
GUI. It implements the QModelIndex based interface required, adds
|
288
|
+
support for drag and drop, and a few other novelWriter-specific
|
289
|
+
methods needed primarily by the project tree GUI component.
|
290
|
+
"""
|
291
|
+
|
292
|
+
__slots__ = ("_tree", "_root")
|
293
|
+
|
294
|
+
def __init__(self, tree: NWTree) -> None:
|
295
|
+
super().__init__()
|
296
|
+
self._tree = tree
|
297
|
+
self._root = ProjectNode(NWItem(tree._project, INV_ROOT))
|
298
|
+
self._root.item.setName("Invisible Root")
|
299
|
+
logger.debug("Ready: ProjectModel")
|
300
|
+
return
|
301
|
+
|
302
|
+
def __del__(self) -> None: # pragma: no cover
|
303
|
+
logger.debug("Delete: ProjectModel")
|
304
|
+
return
|
305
|
+
|
306
|
+
##
|
307
|
+
# Properties
|
308
|
+
##
|
309
|
+
|
310
|
+
@property
|
311
|
+
def root(self) -> ProjectNode:
|
312
|
+
"""Return the model root item."""
|
313
|
+
return self._root
|
314
|
+
|
315
|
+
##
|
316
|
+
# Model Interface
|
317
|
+
##
|
318
|
+
|
319
|
+
def rowCount(self, index: QModelIndex) -> int:
|
320
|
+
"""Return the number of rows for an entry."""
|
321
|
+
if index.isValid():
|
322
|
+
return index.internalPointer().childCount()
|
323
|
+
return self._root.childCount()
|
324
|
+
|
325
|
+
def columnCount(self, index: QModelIndex) -> int:
|
326
|
+
"""Return the number of columns for an entry."""
|
327
|
+
return 4
|
328
|
+
|
329
|
+
def parent(self, index: QModelIndex) -> QModelIndex:
|
330
|
+
"""Get the parent model index of another index."""
|
331
|
+
if index.isValid() and (parent := index.internalPointer().parent()):
|
332
|
+
return self.createIndex(parent.row(), 0, parent)
|
333
|
+
return QModelIndex()
|
334
|
+
|
335
|
+
def index(self, row: int, column: int, parent: QModelIndex = QModelIndex()) -> QModelIndex:
|
336
|
+
"""get the index of a child item of a parent."""
|
337
|
+
if self.hasIndex(row, column, parent):
|
338
|
+
node: ProjectNode = parent.internalPointer() if parent.isValid() else self._root
|
339
|
+
if child := node.child(row):
|
340
|
+
return self.createIndex(row, column, child)
|
341
|
+
return QModelIndex()
|
342
|
+
|
343
|
+
def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> T_NodeData:
|
344
|
+
"""Return display data for a project node."""
|
345
|
+
if index.isValid():
|
346
|
+
return index.internalPointer().data(index.column(), role)
|
347
|
+
return None
|
348
|
+
|
349
|
+
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
|
350
|
+
"""Return flags for a project node."""
|
351
|
+
if index.isValid():
|
352
|
+
return index.internalPointer().flags()
|
353
|
+
return Qt.ItemFlag.NoItemFlags
|
354
|
+
|
355
|
+
##
|
356
|
+
# Drag and Drop
|
357
|
+
##
|
358
|
+
|
359
|
+
def supportedDropActions(self) -> Qt.DropAction:
|
360
|
+
"""Return supported drop actions"""
|
361
|
+
return Qt.DropAction.MoveAction
|
362
|
+
|
363
|
+
def mimeTypes(self) -> list[str]:
|
364
|
+
"""Return the supported mime types of the model."""
|
365
|
+
return [nwConst.MIME_HANDLE]
|
366
|
+
|
367
|
+
def mimeData(self, indices: list[QModelIndex]) -> QMimeData:
|
368
|
+
"""Encode mime data about a selection."""
|
369
|
+
handles = [
|
370
|
+
i.internalPointer().item.itemHandle
|
371
|
+
for i in indices if i.isValid() and i.column() == 0
|
372
|
+
]
|
373
|
+
mime = QMimeData()
|
374
|
+
encodeMimeHandles(mime, handles)
|
375
|
+
return mime
|
376
|
+
|
377
|
+
def canDropMimeData(
|
378
|
+
self, data: QMimeData, action: Qt.DropAction,
|
379
|
+
row: int, column: int, parent: QModelIndex
|
380
|
+
) -> bool:
|
381
|
+
"""Check if mime data can be dropped on the current location."""
|
382
|
+
return data.hasFormat(nwConst.MIME_HANDLE) and action == Qt.DropAction.MoveAction
|
383
|
+
|
384
|
+
def dropMimeData(
|
385
|
+
self, data: QMimeData, action: Qt.DropAction,
|
386
|
+
row: int, column: int, parent: QModelIndex
|
387
|
+
) -> bool:
|
388
|
+
"""Process mime data drop."""
|
389
|
+
if self.canDropMimeData(data, action, row, column, parent):
|
390
|
+
items = []
|
391
|
+
for handle in decodeMimeHandles(data):
|
392
|
+
if (index := self.indexFromHandle(handle)).isValid():
|
393
|
+
items.append(index)
|
394
|
+
self.multiMove(items, parent, row)
|
395
|
+
return True
|
396
|
+
return False
|
397
|
+
|
398
|
+
##
|
399
|
+
# Data Access
|
400
|
+
##
|
401
|
+
|
402
|
+
def row(self, index: QModelIndex) -> int:
|
403
|
+
"""Return the row number of the index."""
|
404
|
+
if index.isValid():
|
405
|
+
return index.internalPointer().row()
|
406
|
+
return -1
|
407
|
+
|
408
|
+
def node(self, index: QModelIndex) -> ProjectNode | None:
|
409
|
+
"""Return the node for a given model index."""
|
410
|
+
if index.isValid():
|
411
|
+
return index.internalPointer()
|
412
|
+
return None
|
413
|
+
|
414
|
+
def nodes(self, indices: list[QModelIndex]) -> list[ProjectNode]:
|
415
|
+
"""Return the nodes for a list of model indices."""
|
416
|
+
return [i.internalPointer() for i in indices if i.isValid() and i.column() == 0]
|
417
|
+
|
418
|
+
def indexFromHandle(self, handle: str | None) -> QModelIndex:
|
419
|
+
"""Get the index representing a node in the model."""
|
420
|
+
if handle and (node := self._tree.nodes.get(handle)):
|
421
|
+
return self.createIndex(node.row(), 0, node)
|
422
|
+
return QModelIndex()
|
423
|
+
|
424
|
+
def indexFromNode(self, node: ProjectNode, column: int = 0) -> QModelIndex:
|
425
|
+
"""Get the index representing a node in the model."""
|
426
|
+
return self.createIndex(node.row(), column, node)
|
427
|
+
|
428
|
+
##
|
429
|
+
# Model Edit
|
430
|
+
##
|
431
|
+
|
432
|
+
def insertChild(self, child: ProjectNode, parent: QModelIndex, pos: int) -> None:
|
433
|
+
"""Insert a node into the model at a given position."""
|
434
|
+
node: ProjectNode = parent.internalPointer() if parent.isValid() else self._root
|
435
|
+
count = node.childCount()
|
436
|
+
row = minmax(pos, 0, count) if pos >= 0 else count
|
437
|
+
self.beginInsertRows(parent, row, row)
|
438
|
+
node.addChild(child, row)
|
439
|
+
self.endInsertRows()
|
440
|
+
return
|
441
|
+
|
442
|
+
def removeChild(self, parent: QModelIndex, pos: int) -> ProjectNode | None:
|
443
|
+
"""Remove a node from the model and return it."""
|
444
|
+
node: ProjectNode = parent.internalPointer() if parent.isValid() else self._root
|
445
|
+
if 0 <= pos < node.childCount():
|
446
|
+
self.beginRemoveRows(parent, pos, pos)
|
447
|
+
child = node.takeChild(pos)
|
448
|
+
self.endRemoveRows()
|
449
|
+
return child
|
450
|
+
return None
|
451
|
+
|
452
|
+
def internalMove(self, index: QModelIndex, step: int) -> None:
|
453
|
+
"""Move an item internally among its siblings."""
|
454
|
+
if index.isValid():
|
455
|
+
node: ProjectNode = index.internalPointer()
|
456
|
+
if parent := node.parent():
|
457
|
+
pos = index.row()
|
458
|
+
new = minmax(pos + step, 0, parent.childCount() - 1)
|
459
|
+
if new != pos:
|
460
|
+
end = new if new < pos else new + 1
|
461
|
+
self.beginMoveRows(index.parent(), pos, pos, index.parent(), end)
|
462
|
+
parent.moveChild(pos, new)
|
463
|
+
self.endMoveRows()
|
464
|
+
return
|
465
|
+
|
466
|
+
def multiMove(self, indices: list[QModelIndex], target: QModelIndex, pos: int = -1) -> None:
|
467
|
+
"""Move multiple items to a new location."""
|
468
|
+
if target.isValid():
|
469
|
+
# This is a two pass process. First we only select unique
|
470
|
+
# non-root items for move, then we do a second pass and only
|
471
|
+
# move those items that don't have a parent also scheduled
|
472
|
+
# for moving or have already been moved. Child items are
|
473
|
+
# moved with the parent.
|
474
|
+
pruned = []
|
475
|
+
handles = set()
|
476
|
+
for index in indices:
|
477
|
+
if index.isValid():
|
478
|
+
node: ProjectNode = index.internalPointer()
|
479
|
+
handle = node.item.itemHandle
|
480
|
+
if node.item.isRootType() is False and handle not in handles:
|
481
|
+
pruned.append(node)
|
482
|
+
handles.add(handle)
|
483
|
+
for node in (reversed(pruned) if pos >= 0 else pruned):
|
484
|
+
if node.item.itemParent not in handles:
|
485
|
+
index = self.indexFromNode(node)
|
486
|
+
if temp := self.removeChild(index.parent(), index.row()):
|
487
|
+
self.insertChild(temp, target, pos)
|
488
|
+
for child in reversed(node.allChildren()):
|
489
|
+
node._updateRelationships(child)
|
490
|
+
child.item.notifyToRefresh()
|
491
|
+
node.item.notifyToRefresh()
|
492
|
+
return
|
493
|
+
|
494
|
+
##
|
495
|
+
# Other Methods
|
496
|
+
##
|
497
|
+
|
498
|
+
def clear(self) -> None:
|
499
|
+
"""Clear the project model."""
|
500
|
+
self._root._children.clear()
|
501
|
+
return
|
502
|
+
|
503
|
+
def allExpanded(self) -> list[QModelIndex]:
|
504
|
+
"""Return a list of all expanded items."""
|
505
|
+
expanded = []
|
506
|
+
for node in self._root.allChildren():
|
507
|
+
if node._item.isExpanded:
|
508
|
+
expanded.append(self.createIndex(node.row(), 0, node))
|
509
|
+
return expanded
|
510
|
+
|
511
|
+
def trashSelection(self, indices: list[QModelIndex]) -> bool:
|
512
|
+
"""Check if a selection of indices are all in trash or not."""
|
513
|
+
for index in indices:
|
514
|
+
if index.isValid():
|
515
|
+
node: ProjectNode = index.internalPointer()
|
516
|
+
if node.item.itemClass != nwItemClass.TRASH:
|
517
|
+
return False
|
518
|
+
return True
|