novelWriter 2.5.3__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.
Files changed (83) hide show
  1. {novelWriter-2.5.3.dist-info → novelWriter-2.6b2.dist-info}/METADATA +1 -1
  2. {novelWriter-2.5.3.dist-info → novelWriter-2.6b2.dist-info}/RECORD +80 -60
  3. novelwriter/__init__.py +49 -10
  4. novelwriter/assets/i18n/project_en_GB.json +1 -0
  5. novelwriter/assets/icons/typicons_dark/icons.conf +8 -0
  6. novelwriter/assets/icons/typicons_dark/mixed_copy.svg +4 -0
  7. novelwriter/assets/icons/typicons_dark/mixed_margin-bottom.svg +6 -0
  8. novelwriter/assets/icons/typicons_dark/mixed_margin-left.svg +6 -0
  9. novelwriter/assets/icons/typicons_dark/mixed_margin-right.svg +6 -0
  10. novelwriter/assets/icons/typicons_dark/mixed_margin-top.svg +6 -0
  11. novelwriter/assets/icons/typicons_dark/mixed_size-height.svg +6 -0
  12. novelwriter/assets/icons/typicons_dark/mixed_size-width.svg +6 -0
  13. novelwriter/assets/icons/typicons_dark/nw_toolbar.svg +5 -0
  14. novelwriter/assets/icons/typicons_light/icons.conf +8 -0
  15. novelwriter/assets/icons/typicons_light/mixed_copy.svg +4 -0
  16. novelwriter/assets/icons/typicons_light/mixed_margin-bottom.svg +6 -0
  17. novelwriter/assets/icons/typicons_light/mixed_margin-left.svg +6 -0
  18. novelwriter/assets/icons/typicons_light/mixed_margin-right.svg +6 -0
  19. novelwriter/assets/icons/typicons_light/mixed_margin-top.svg +6 -0
  20. novelwriter/assets/icons/typicons_light/mixed_size-height.svg +6 -0
  21. novelwriter/assets/icons/typicons_light/mixed_size-width.svg +6 -0
  22. novelwriter/assets/icons/typicons_light/nw_toolbar.svg +5 -0
  23. novelwriter/assets/manual.pdf +0 -0
  24. novelwriter/assets/sample.zip +0 -0
  25. novelwriter/common.py +100 -2
  26. novelwriter/config.py +25 -15
  27. novelwriter/constants.py +168 -60
  28. novelwriter/core/buildsettings.py +66 -39
  29. novelwriter/core/coretools.py +145 -147
  30. novelwriter/core/docbuild.py +132 -170
  31. novelwriter/core/index.py +38 -37
  32. novelwriter/core/item.py +41 -8
  33. novelwriter/core/itemmodel.py +518 -0
  34. novelwriter/core/options.py +4 -1
  35. novelwriter/core/project.py +67 -89
  36. novelwriter/core/spellcheck.py +9 -14
  37. novelwriter/core/status.py +7 -5
  38. novelwriter/core/tree.py +268 -287
  39. novelwriter/dialogs/docmerge.py +7 -17
  40. novelwriter/dialogs/preferences.py +46 -33
  41. novelwriter/dialogs/projectsettings.py +5 -5
  42. novelwriter/enum.py +36 -23
  43. novelwriter/extensions/configlayout.py +27 -12
  44. novelwriter/extensions/modified.py +13 -1
  45. novelwriter/extensions/pagedsidebar.py +5 -5
  46. novelwriter/formats/shared.py +155 -0
  47. novelwriter/formats/todocx.py +1191 -0
  48. novelwriter/formats/tohtml.py +451 -0
  49. novelwriter/{core → formats}/tokenizer.py +487 -491
  50. novelwriter/formats/tomarkdown.py +217 -0
  51. novelwriter/{core → formats}/toodt.py +311 -432
  52. novelwriter/formats/toqdoc.py +484 -0
  53. novelwriter/formats/toraw.py +91 -0
  54. novelwriter/gui/doceditor.py +342 -284
  55. novelwriter/gui/dochighlight.py +96 -84
  56. novelwriter/gui/docviewer.py +88 -31
  57. novelwriter/gui/docviewerpanel.py +17 -25
  58. novelwriter/gui/editordocument.py +17 -2
  59. novelwriter/gui/itemdetails.py +25 -28
  60. novelwriter/gui/mainmenu.py +129 -63
  61. novelwriter/gui/noveltree.py +45 -47
  62. novelwriter/gui/outline.py +196 -249
  63. novelwriter/gui/projtree.py +594 -1241
  64. novelwriter/gui/search.py +9 -10
  65. novelwriter/gui/sidebar.py +7 -6
  66. novelwriter/gui/theme.py +10 -5
  67. novelwriter/guimain.py +100 -196
  68. novelwriter/shared.py +66 -27
  69. novelwriter/text/counting.py +2 -0
  70. novelwriter/text/patterns.py +168 -60
  71. novelwriter/tools/manusbuild.py +14 -12
  72. novelwriter/tools/manuscript.py +120 -78
  73. novelwriter/tools/manussettings.py +424 -291
  74. novelwriter/tools/welcome.py +4 -4
  75. novelwriter/tools/writingstats.py +3 -3
  76. novelwriter/types.py +23 -7
  77. novelwriter/core/tohtml.py +0 -530
  78. novelwriter/core/tomarkdown.py +0 -252
  79. novelwriter/core/toqdoc.py +0 -419
  80. {novelWriter-2.5.3.dist-info → novelWriter-2.6b2.dist-info}/LICENSE.md +0 -0
  81. {novelWriter-2.5.3.dist-info → novelWriter-2.6b2.dist-info}/WHEEL +0 -0
  82. {novelWriter-2.5.3.dist-info → novelWriter-2.6b2.dist-info}/entry_points.txt +0 -0
  83. {novelWriter-2.5.3.dist-info → novelWriter-2.6b2.dist-info}/top_level.txt +0 -0
novelwriter/core/item.py CHANGED
@@ -27,13 +27,14 @@ 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
35
36
  )
36
- from novelwriter.constants import nwHeaders, nwLabels, trConst
37
+ from novelwriter.constants import nwLabels, nwStyles, trConst
37
38
  from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
38
39
 
39
40
  if TYPE_CHECKING: # pragma: no cover
@@ -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
  ##
@@ -518,7 +556,7 @@ class NWItem:
518
556
 
519
557
  def setMainHeading(self, value: str) -> None:
520
558
  """Set the main heading level."""
521
- if value in nwHeaders.H_LEVEL:
559
+ if value in nwStyles.H_LEVEL:
522
560
  self._heading = value
523
561
  return
524
562
 
@@ -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
@@ -60,7 +60,7 @@ VALID_MAP: dict[str, set[str]] = {
60
60
  },
61
61
  "GuiManuscript": {
62
62
  "winWidth", "winHeight", "optsWidth", "viewWidth", "listHeight",
63
- "detailsHeight", "detailsWidth", "detailsExpanded",
63
+ "detailsHeight", "detailsWidth", "detailsExpanded", "showNewPage",
64
64
  },
65
65
  "GuiManuscriptBuild": {
66
66
  "winWidth", "winHeight", "fmtWidth", "sumWidth",
@@ -73,6 +73,9 @@ VALID_MAP: dict[str, set[str]] = {
73
73
  "widthCol3", "widthCol4", "wordsPerPage", "countFrom", "clearDouble",
74
74
  "novelRoot",
75
75
  },
76
+ "GuiOutlineDetails": {
77
+ "detailsWidth", "tagsWidth",
78
+ }
76
79
  }
77
80
 
78
81