novelWriter 2.3.1__py3-none-any.whl → 2.4__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 (119) hide show
  1. {novelWriter-2.3.1.dist-info → novelWriter-2.4.dist-info}/METADATA +5 -6
  2. {novelWriter-2.3.1.dist-info → novelWriter-2.4.dist-info}/RECORD +114 -107
  3. novelwriter/__init__.py +17 -10
  4. novelwriter/assets/i18n/nw_de_DE.qm +0 -0
  5. novelwriter/assets/i18n/nw_en_US.qm +0 -0
  6. novelwriter/assets/i18n/nw_es_419.qm +0 -0
  7. novelwriter/assets/i18n/nw_fr_FR.qm +0 -0
  8. novelwriter/assets/i18n/nw_it_IT.qm +0 -0
  9. novelwriter/assets/i18n/nw_ja_JP.qm +0 -0
  10. novelwriter/assets/i18n/nw_nb_NO.qm +0 -0
  11. novelwriter/assets/i18n/nw_nl_NL.qm +0 -0
  12. novelwriter/assets/i18n/nw_pt_BR.qm +0 -0
  13. novelwriter/assets/i18n/nw_zh_CN.qm +0 -0
  14. novelwriter/assets/icons/none.svg +4 -0
  15. novelwriter/assets/icons/typicons_dark/icons.conf +4 -0
  16. novelwriter/assets/icons/typicons_dark/nw_tb-mark.svg +7 -0
  17. novelwriter/assets/icons/typicons_dark/typ_refresh-flipped.svg +1 -1
  18. novelwriter/assets/icons/typicons_dark/typ_refresh.svg +1 -1
  19. novelwriter/assets/icons/typicons_dark/typ_search-grey.svg +4 -0
  20. novelwriter/assets/icons/typicons_dark/typ_times.svg +1 -1
  21. novelwriter/assets/icons/typicons_dark/typ_unfold-hidden.svg +4 -0
  22. novelwriter/assets/icons/typicons_dark/typ_unfold-visible.svg +4 -0
  23. novelwriter/assets/icons/typicons_light/icons.conf +4 -0
  24. novelwriter/assets/icons/typicons_light/nw_tb-mark.svg +7 -0
  25. novelwriter/assets/icons/typicons_light/typ_refresh-flipped.svg +1 -1
  26. novelwriter/assets/icons/typicons_light/typ_refresh.svg +1 -1
  27. novelwriter/assets/icons/typicons_light/typ_search-grey.svg +4 -0
  28. novelwriter/assets/icons/typicons_light/typ_times.svg +1 -1
  29. novelwriter/assets/icons/typicons_light/typ_unfold-hidden.svg +4 -0
  30. novelwriter/assets/icons/typicons_light/typ_unfold-visible.svg +4 -0
  31. novelwriter/assets/manual.pdf +0 -0
  32. novelwriter/assets/sample.zip +0 -0
  33. novelwriter/assets/syntax/default_dark.conf +1 -0
  34. novelwriter/assets/syntax/default_light.conf +1 -0
  35. novelwriter/assets/syntax/grey_dark.conf +1 -0
  36. novelwriter/assets/syntax/grey_light.conf +1 -0
  37. novelwriter/assets/syntax/light_owl.conf +1 -0
  38. novelwriter/assets/syntax/night_owl.conf +1 -0
  39. novelwriter/assets/syntax/solarized_dark.conf +1 -0
  40. novelwriter/assets/syntax/solarized_light.conf +1 -0
  41. novelwriter/assets/syntax/tomorrow.conf +1 -0
  42. novelwriter/assets/syntax/tomorrow_night.conf +1 -0
  43. novelwriter/assets/syntax/tomorrow_night_blue.conf +1 -0
  44. novelwriter/assets/syntax/tomorrow_night_bright.conf +1 -0
  45. novelwriter/assets/syntax/tomorrow_night_eighties.conf +1 -0
  46. novelwriter/assets/text/credits_en.htm +25 -23
  47. novelwriter/common.py +12 -4
  48. novelwriter/config.py +47 -16
  49. novelwriter/constants.py +5 -6
  50. novelwriter/core/buildsettings.py +64 -44
  51. novelwriter/core/coretools.py +97 -13
  52. novelwriter/core/docbuild.py +74 -7
  53. novelwriter/core/document.py +24 -3
  54. novelwriter/core/index.py +31 -112
  55. novelwriter/core/project.py +10 -15
  56. novelwriter/core/projectxml.py +1 -1
  57. novelwriter/core/sessions.py +2 -2
  58. novelwriter/core/spellcheck.py +3 -3
  59. novelwriter/core/status.py +6 -5
  60. novelwriter/core/storage.py +8 -2
  61. novelwriter/core/tohtml.py +22 -25
  62. novelwriter/core/tokenizer.py +417 -233
  63. novelwriter/core/tomd.py +17 -8
  64. novelwriter/core/toodt.py +386 -351
  65. novelwriter/core/tree.py +8 -8
  66. novelwriter/dialogs/about.py +9 -11
  67. novelwriter/dialogs/docmerge.py +17 -14
  68. novelwriter/dialogs/docsplit.py +20 -19
  69. novelwriter/dialogs/editlabel.py +5 -4
  70. novelwriter/dialogs/preferences.py +32 -40
  71. novelwriter/dialogs/projectsettings.py +31 -28
  72. novelwriter/dialogs/quotes.py +10 -9
  73. novelwriter/dialogs/wordlist.py +17 -14
  74. novelwriter/enum.py +17 -14
  75. novelwriter/error.py +14 -12
  76. novelwriter/extensions/circularprogress.py +12 -8
  77. novelwriter/extensions/configlayout.py +1 -3
  78. novelwriter/extensions/modified.py +51 -2
  79. novelwriter/extensions/pagedsidebar.py +16 -14
  80. novelwriter/extensions/simpleprogress.py +3 -1
  81. novelwriter/extensions/statusled.py +3 -1
  82. novelwriter/extensions/switch.py +10 -9
  83. novelwriter/extensions/switchbox.py +14 -13
  84. novelwriter/extensions/versioninfo.py +1 -1
  85. novelwriter/gui/doceditor.py +433 -496
  86. novelwriter/gui/dochighlight.py +54 -33
  87. novelwriter/gui/docviewer.py +162 -175
  88. novelwriter/gui/docviewerpanel.py +20 -37
  89. novelwriter/gui/editordocument.py +15 -4
  90. novelwriter/gui/itemdetails.py +51 -54
  91. novelwriter/gui/mainmenu.py +37 -16
  92. novelwriter/gui/noveltree.py +31 -37
  93. novelwriter/gui/outline.py +120 -98
  94. novelwriter/gui/projtree.py +61 -67
  95. novelwriter/gui/search.py +362 -0
  96. novelwriter/gui/sidebar.py +36 -45
  97. novelwriter/gui/statusbar.py +14 -14
  98. novelwriter/gui/theme.py +107 -32
  99. novelwriter/guimain.py +209 -202
  100. novelwriter/shared.py +31 -6
  101. novelwriter/text/counting.py +138 -0
  102. novelwriter/tools/dictionaries.py +15 -14
  103. novelwriter/tools/lipsum.py +20 -17
  104. novelwriter/tools/manusbuild.py +43 -35
  105. novelwriter/tools/manuscript.py +381 -104
  106. novelwriter/tools/manussettings.py +262 -125
  107. novelwriter/tools/noveldetails.py +20 -18
  108. novelwriter/tools/welcome.py +52 -49
  109. novelwriter/tools/writingstats.py +61 -55
  110. novelwriter/types.py +90 -0
  111. novelwriter/core/__init__.py +0 -3
  112. novelwriter/dialogs/__init__.py +0 -3
  113. novelwriter/extensions/__init__.py +0 -3
  114. novelwriter/gui/__init__.py +0 -3
  115. novelwriter/tools/__init__.py +0 -3
  116. {novelWriter-2.3.1.dist-info → novelWriter-2.4.dist-info}/LICENSE.md +0 -0
  117. {novelWriter-2.3.1.dist-info → novelWriter-2.4.dist-info}/WHEEL +0 -0
  118. {novelWriter-2.3.1.dist-info → novelWriter-2.4.dist-info}/entry_points.txt +0 -0
  119. {novelWriter-2.3.1.dist-info → novelWriter-2.4.dist-info}/top_level.txt +0 -0
@@ -32,26 +32,29 @@ from enum import Enum
32
32
  from time import time
33
33
  from typing import TYPE_CHECKING
34
34
 
35
+ from PyQt5.QtCore import QPoint, QTimer, Qt, pyqtSignal, pyqtSlot
35
36
  from PyQt5.QtGui import (
36
37
  QDragEnterEvent, QDragMoveEvent, QDropEvent, QIcon, QMouseEvent, QPalette
37
38
  )
38
- from PyQt5.QtCore import QPoint, QTimer, Qt, QSize, pyqtSignal, pyqtSlot
39
39
  from PyQt5.QtWidgets import (
40
40
  QAbstractItemView, QAction, QDialog, QFrame, QHBoxLayout, QHeaderView,
41
- QLabel, QMenu, QShortcut, QSizePolicy, QToolButton, QTreeWidget,
42
- QTreeWidgetItem, QVBoxLayout, QWidget
41
+ QLabel, QMenu, QShortcut, QSizePolicy, QTreeWidget, QTreeWidgetItem,
42
+ QVBoxLayout, QWidget
43
43
  )
44
44
 
45
45
  from novelwriter import CONFIG, SHARED
46
- from novelwriter.enum import nwDocMode, nwItemType, nwItemClass, nwItemLayout
47
46
  from novelwriter.common import minmax
48
47
  from novelwriter.constants import nwHeaders, nwUnicode, trConst, nwLabels
49
- from novelwriter.core.item import NWItem
50
48
  from novelwriter.core.coretools import DocDuplicator, DocMerger, DocSplitter
49
+ from novelwriter.core.item import NWItem
51
50
  from novelwriter.dialogs.docmerge import GuiDocMerge
52
51
  from novelwriter.dialogs.docsplit import GuiDocSplit
53
52
  from novelwriter.dialogs.editlabel import GuiEditLabel
54
53
  from novelwriter.dialogs.projectsettings import GuiProjectSettings
54
+ from novelwriter.enum import nwDocMode, nwItemType, nwItemClass, nwItemLayout
55
+ from novelwriter.extensions.modified import NIconToolButton
56
+ from novelwriter.gui.theme import STYLES_MIN_TOOLBUTTON
57
+ from novelwriter.types import QtAlignLeft, QtAlignRight, QtMouseLeft, QtMouseMiddle, QtUserRole
55
58
 
56
59
  if TYPE_CHECKING: # pragma: no cover
57
60
  from novelwriter.guimain import GuiMain
@@ -142,7 +145,6 @@ class GuiProjectView(QWidget):
142
145
  self.requestDeleteItem = self.projTree.requestDeleteItem
143
146
  self.getSelectedHandle = self.projTree.getSelectedHandle
144
147
  self.changedSince = self.projTree.changedSince
145
- self.createNewNote = self.projTree.createNewNote
146
148
 
147
149
  return
148
150
 
@@ -240,6 +242,12 @@ class GuiProjectView(QWidget):
240
242
  self.projBar.buildQuickLinksMenu()
241
243
  return
242
244
 
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
+
243
251
  # END Class GuiProjectView
244
252
 
245
253
 
@@ -256,36 +264,33 @@ class GuiProjectToolBar(QWidget):
256
264
  self.projTree = projView.projTree
257
265
  self.mainGui = projView.mainGui
258
266
 
259
- iPx = SHARED.theme.baseIconSize
267
+ iSz = SHARED.theme.baseIconSize
260
268
  mPx = CONFIG.pxInt(2)
261
269
 
262
270
  self.setContentsMargins(0, 0, 0, 0)
263
271
  self.setAutoFillBackground(True)
264
272
 
265
273
  # Widget Label
266
- self.viewLabel = QLabel("<b>%s</b>" % self.tr("Project Content"))
274
+ self.viewLabel = QLabel(self.tr("Project Content"), self)
275
+ self.viewLabel.setFont(SHARED.theme.guiFontB)
267
276
  self.viewLabel.setContentsMargins(0, 0, 0, 0)
268
277
  self.viewLabel.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
269
278
 
270
279
  # Quick Links
271
280
  self.mQuick = QMenu(self)
272
281
 
273
- self.tbQuick = QToolButton(self)
282
+ self.tbQuick = NIconToolButton(self, iSz)
274
283
  self.tbQuick.setToolTip("%s [Ctrl+L]" % self.tr("Quick Links"))
275
284
  self.tbQuick.setShortcut("Ctrl+L")
276
- self.tbQuick.setIconSize(QSize(iPx, iPx))
277
285
  self.tbQuick.setMenu(self.mQuick)
278
- self.tbQuick.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
279
286
 
280
287
  # Move Buttons
281
- self.tbMoveU = QToolButton(self)
288
+ self.tbMoveU = NIconToolButton(self, iSz)
282
289
  self.tbMoveU.setToolTip("%s [Ctrl+Up]" % self.tr("Move Up"))
283
- self.tbMoveU.setIconSize(QSize(iPx, iPx))
284
290
  self.tbMoveU.clicked.connect(lambda: self.projTree.moveTreeItem(-1))
285
291
 
286
- self.tbMoveD = QToolButton(self)
292
+ self.tbMoveD = NIconToolButton(self, iSz)
287
293
  self.tbMoveD.setToolTip("%s [Ctrl+Down]" % self.tr("Move Down"))
288
- self.tbMoveD.setIconSize(QSize(iPx, iPx))
289
294
  self.tbMoveD.clicked.connect(lambda: self.projTree.moveTreeItem(1))
290
295
 
291
296
  # Add Item Menu
@@ -324,12 +329,10 @@ class GuiProjectToolBar(QWidget):
324
329
  self.mAddRoot = self.mAdd.addMenu(trConst(nwLabels.ITEM_DESCRIPTION["root"]))
325
330
  self._buildRootMenu()
326
331
 
327
- self.tbAdd = QToolButton(self)
332
+ self.tbAdd = NIconToolButton(self, iSz)
328
333
  self.tbAdd.setToolTip("%s [Ctrl+N]" % self.tr("Add Item"))
329
334
  self.tbAdd.setShortcut("Ctrl+N")
330
- self.tbAdd.setIconSize(QSize(iPx, iPx))
331
335
  self.tbAdd.setMenu(self.mAdd)
332
- self.tbAdd.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
333
336
 
334
337
  # More Options Menu
335
338
  self.mMore = QMenu(self)
@@ -343,11 +346,9 @@ class GuiProjectToolBar(QWidget):
343
346
  self.aEmptyTrash = self.mMore.addAction(self.tr("Empty Trash"))
344
347
  self.aEmptyTrash.triggered.connect(lambda: self.projTree.emptyTrash())
345
348
 
346
- self.tbMore = QToolButton(self)
349
+ self.tbMore = NIconToolButton(self, iSz)
347
350
  self.tbMore.setToolTip(self.tr("More Options"))
348
- self.tbMore.setIconSize(QSize(iPx, iPx))
349
351
  self.tbMore.setMenu(self.mMore)
350
- self.tbMore.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
351
352
 
352
353
  # Assemble
353
354
  self.outerBox = QHBoxLayout()
@@ -377,29 +378,24 @@ class GuiProjectToolBar(QWidget):
377
378
  qPalette.setBrush(QPalette.ColorRole.Window, qPalette.base())
378
379
  self.setPalette(qPalette)
379
380
 
380
- fadeCol = qPalette.text().color()
381
- buttonStyle = (
382
- "QToolButton {{padding: {0}px; border: none; background: transparent;}} "
383
- "QToolButton:hover {{border: none; background: rgba({1},{2},{3},0.2);}}"
384
- ).format(CONFIG.pxInt(2), fadeCol.red(), fadeCol.green(), fadeCol.blue())
385
- buttonStyleMenu = f"{buttonStyle} QToolButton::menu-indicator {{image: none;}}"
386
-
387
- self.tbQuick.setStyleSheet(buttonStyleMenu)
381
+ buttonStyle = SHARED.theme.getStyleSheet(STYLES_MIN_TOOLBUTTON)
382
+ self.tbQuick.setStyleSheet(buttonStyle)
388
383
  self.tbMoveU.setStyleSheet(buttonStyle)
389
384
  self.tbMoveD.setStyleSheet(buttonStyle)
390
- self.tbAdd.setStyleSheet(buttonStyleMenu)
391
- self.tbMore.setStyleSheet(buttonStyleMenu)
385
+ self.tbAdd.setStyleSheet(buttonStyle)
386
+ self.tbMore.setStyleSheet(buttonStyle)
387
+
388
+ self.tbQuick.setThemeIcon("bookmark")
389
+ self.tbMoveU.setThemeIcon("up")
390
+ self.tbMoveD.setThemeIcon("down")
391
+ self.tbAdd.setThemeIcon("add")
392
+ self.tbMore.setThemeIcon("menu")
392
393
 
393
- self.tbQuick.setIcon(SHARED.theme.getIcon("bookmark"))
394
- self.tbMoveU.setIcon(SHARED.theme.getIcon("up"))
395
- self.tbMoveD.setIcon(SHARED.theme.getIcon("down"))
396
394
  self.aAddEmpty.setIcon(SHARED.theme.getIcon("proj_document"))
397
395
  self.aAddChap.setIcon(SHARED.theme.getIcon("proj_chapter"))
398
396
  self.aAddScene.setIcon(SHARED.theme.getIcon("proj_scene"))
399
397
  self.aAddNote.setIcon(SHARED.theme.getIcon("proj_note"))
400
398
  self.aAddFolder.setIcon(SHARED.theme.getIcon("proj_folder"))
401
- self.tbAdd.setIcon(SHARED.theme.getIcon("add"))
402
- self.tbMore.setIcon(SHARED.theme.getIcon("menu"))
403
399
 
404
400
  self.buildQuickLinksMenu()
405
401
  self._buildRootMenu()
@@ -457,7 +453,7 @@ class GuiProjectToolBar(QWidget):
457
453
 
458
454
  def _buildRootMenu(self) -> None:
459
455
  """Build the rood folder menu."""
460
- def addClass(itemClass):
456
+ def addClass(itemClass: nwItemClass) -> None:
461
457
  aNew = self.mAddRoot.addAction(trConst(nwLabels.CLASS_NAME[itemClass]))
462
458
  aNew.setIcon(SHARED.theme.getIcon(nwLabels.CLASS_ICON[itemClass]))
463
459
  aNew.triggered.connect(lambda: self.projTree.newTreeItem(nwItemType.ROOT, itemClass))
@@ -491,8 +487,8 @@ class GuiProjectTree(QTreeWidget):
491
487
  C_ACTIVE = 2
492
488
  C_STATUS = 3
493
489
 
494
- D_HANDLE = Qt.ItemDataRole.UserRole
495
- D_WORDS = Qt.ItemDataRole.UserRole + 1
490
+ D_HANDLE = QtUserRole
491
+ D_WORDS = QtUserRole + 1
496
492
 
497
493
  itemRefreshed = pyqtSignal(str, NWItem, QIcon)
498
494
 
@@ -522,10 +518,10 @@ class GuiProjectTree(QTreeWidget):
522
518
  self.customContextMenuRequested.connect(self._openContextMenu)
523
519
 
524
520
  # Tree Settings
525
- iPx = SHARED.theme.baseIconSize
521
+ iPx = SHARED.theme.baseIconHeight
526
522
  cMg = CONFIG.pxInt(6)
527
523
 
528
- self.setIconSize(QSize(iPx, iPx))
524
+ self.setIconSize(SHARED.theme.baseIconSize)
529
525
  self.setFrameStyle(QFrame.Shape.NoFrame)
530
526
  self.setUniformRowHeights(True)
531
527
  self.setAllColumnsShowFocus(True)
@@ -569,9 +565,9 @@ class GuiProjectTree(QTreeWidget):
569
565
  self.itemSelectionChanged.connect(self._treeSelectionChange)
570
566
 
571
567
  # Auto Scroll
572
- self._scrollMargin = SHARED.theme.baseIconSize
568
+ self._scrollMargin = SHARED.theme.baseIconHeight
573
569
  self._scrollDirection = 0
574
- self._scrollTimer = QTimer()
570
+ self._scrollTimer = QTimer(self)
575
571
  self._scrollTimer.timeout.connect(self._doAutoScroll)
576
572
  self._scrollTimer.setInterval(250)
577
573
 
@@ -606,18 +602,18 @@ class GuiProjectTree(QTreeWidget):
606
602
  self._timeChanged = 0.0
607
603
  return
608
604
 
609
- def createNewNote(self, tag: str, itemClass: nwItemClass | None) -> bool:
605
+ def createNewNote(self, tag: str, itemClass: nwItemClass) -> None:
610
606
  """Create a new note. This function is used by the document
611
607
  editor to create note files for unknown tags.
612
608
  """
613
- rHandle = SHARED.project.tree.findRoot(itemClass)
614
- if rHandle:
615
- tHandle = SHARED.project.newFile(tag, rHandle)
616
- if tHandle:
609
+ if itemClass != nwItemClass.NO_CLASS:
610
+ if not (rHandle := SHARED.project.tree.findRoot(itemClass)):
611
+ self.newTreeItem(nwItemType.ROOT, itemClass)
612
+ rHandle = SHARED.project.tree.findRoot(itemClass)
613
+ if rHandle and (tHandle := SHARED.project.newFile(tag, rHandle)):
617
614
  SHARED.project.writeNewFile(tHandle, 1, False, f"@tag: {tag}\n\n")
618
615
  self.revealNewTreeItem(tHandle, wordCount=True)
619
- return True
620
- return False
616
+ return
621
617
 
622
618
  def newTreeItem(self, itemType: nwItemType, itemClass: nwItemClass | None = None,
623
619
  hLevel: int = 1, isNote: bool = False, copyDoc: str | None = None) -> bool:
@@ -1234,7 +1230,7 @@ class GuiProjectTree(QTreeWidget):
1234
1230
  else:
1235
1231
  ctxMenu.buildSingleSelectMenu(hasChild)
1236
1232
 
1237
- ctxMenu.exec_(self.viewport().mapToGlobal(clickPos))
1233
+ ctxMenu.exec(self.viewport().mapToGlobal(clickPos))
1238
1234
  ctxMenu.deleteLater()
1239
1235
 
1240
1236
  return True
@@ -1260,11 +1256,11 @@ class GuiProjectTree(QTreeWidget):
1260
1256
  for viewing if the user middle-clicked.
1261
1257
  """
1262
1258
  super().mousePressEvent(event)
1263
- if event.button() == Qt.MouseButton.LeftButton:
1259
+ if event.button() == QtMouseLeft:
1264
1260
  selItem = self.indexAt(event.pos())
1265
1261
  if not selItem.isValid():
1266
1262
  self.clearSelection()
1267
- elif event.button() == Qt.MouseButton.MiddleButton:
1263
+ elif event.button() == QtMouseMiddle:
1268
1264
  selItem = self.itemAt(event.pos())
1269
1265
  if selItem:
1270
1266
  tHandle = selItem.data(self.C_DATA, self.D_HANDLE)
@@ -1272,7 +1268,7 @@ class GuiProjectTree(QTreeWidget):
1272
1268
  self.projView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, "", False)
1273
1269
  return
1274
1270
 
1275
- def startDrag(self, dropAction: Qt.DropActions) -> None:
1271
+ def startDrag(self, dropAction: Qt.DropAction) -> None:
1276
1272
  """Capture the drag and drop handling to pop alerts."""
1277
1273
  super().startDrag(dropAction)
1278
1274
  if self._popAlert:
@@ -1414,7 +1410,7 @@ class GuiProjectTree(QTreeWidget):
1414
1410
  itemList.remove(tHandle)
1415
1411
 
1416
1412
  dlgMerge = GuiDocMerge(self.mainGui, tHandle, itemList)
1417
- dlgMerge.exec_()
1413
+ dlgMerge.exec()
1418
1414
 
1419
1415
  if dlgMerge.result() == QDialog.DialogCode.Accepted:
1420
1416
 
@@ -1484,7 +1480,7 @@ class GuiProjectTree(QTreeWidget):
1484
1480
  return False
1485
1481
 
1486
1482
  dlgSplit = GuiDocSplit(self.mainGui, tHandle)
1487
- dlgSplit.exec_()
1483
+ dlgSplit.exec()
1488
1484
 
1489
1485
  if dlgSplit.result() == QDialog.DialogCode.Accepted:
1490
1486
 
@@ -1591,10 +1587,10 @@ class GuiProjectTree(QTreeWidget):
1591
1587
  newItem.setText(self.C_ACTIVE, "")
1592
1588
  newItem.setText(self.C_STATUS, "")
1593
1589
 
1594
- newItem.setTextAlignment(self.C_NAME, Qt.AlignmentFlag.AlignLeft)
1595
- newItem.setTextAlignment(self.C_COUNT, Qt.AlignmentFlag.AlignRight)
1596
- newItem.setTextAlignment(self.C_ACTIVE, Qt.AlignmentFlag.AlignLeft)
1597
- newItem.setTextAlignment(self.C_STATUS, Qt.AlignmentFlag.AlignLeft)
1590
+ newItem.setTextAlignment(self.C_NAME, QtAlignLeft)
1591
+ newItem.setTextAlignment(self.C_COUNT, QtAlignRight)
1592
+ newItem.setTextAlignment(self.C_ACTIVE, QtAlignLeft)
1593
+ newItem.setTextAlignment(self.C_STATUS, QtAlignLeft)
1598
1594
 
1599
1595
  newItem.setData(self.C_DATA, self.D_HANDLE, tHandle)
1600
1596
  newItem.setData(self.C_DATA, self.D_WORDS, 0)
@@ -1834,7 +1830,7 @@ class _TreeContextMenu(QMenu):
1834
1830
 
1835
1831
  def _itemHeader(self) -> None:
1836
1832
  """Check if there is a header that can be used for rename."""
1837
- if hItem := SHARED.project.index.getItemHeader(self._handle, "T0001"):
1833
+ if hItem := SHARED.project.index.getItemHeading(self._handle, "T0001"):
1838
1834
  action = self.addAction(self.tr("Rename to Heading"))
1839
1835
  action.triggered.connect(
1840
1836
  lambda: self.projTree.renameTreeItem(self._handle, hItem.title)
@@ -1933,7 +1929,7 @@ class _TreeContextMenu(QMenu):
1933
1929
  action.triggered.connect(lambda: tree._mergeDocuments(tHandle, True))
1934
1930
 
1935
1931
  if isFile:
1936
- action = menu.addAction(self.tr("Split Document by Headers"))
1932
+ action = menu.addAction(self.tr("Split Document by Headings"))
1937
1933
  action.triggered.connect(lambda: tree._splitDocument(tHandle))
1938
1934
 
1939
1935
  return
@@ -1947,11 +1943,9 @@ class _TreeContextMenu(QMenu):
1947
1943
  action.triggered.connect(lambda: tree.setExpandedFromHandle(tHandle, True))
1948
1944
  action = self.addAction(self.tr("Collapse All"))
1949
1945
  action.triggered.connect(lambda: tree.setExpandedFromHandle(tHandle, False))
1950
- action = self.addAction(self.tr("Duplicate from Here"))
1951
- action.triggered.connect(lambda: tree._duplicateFromHandle(tHandle))
1952
- elif isFile:
1953
- action = self.addAction(self.tr("Duplicate Document"))
1954
- action.triggered.connect(lambda: tree._duplicateFromHandle(tHandle))
1946
+
1947
+ action = self.addAction(self.tr("Duplicate"))
1948
+ action.triggered.connect(lambda: tree._duplicateFromHandle(tHandle))
1955
1949
 
1956
1950
  if self._item.itemClass == nwItemClass.TRASH or isRoot or (isFolder and not hasChild):
1957
1951
  action = self.addAction(self.tr("Delete Permanently"))
@@ -0,0 +1,362 @@
1
+ """
2
+ novelWriter – GUI Project Search
3
+ ================================
4
+
5
+ File History:
6
+ Created: 2024-03-21 [2.4b1] GuiProjectSearch
7
+
8
+ This file is a part of novelWriter
9
+ Copyright 2018–2024, Veronica Berglyd Olsen
10
+
11
+ This program is free software: you can redistribute it and/or modify
12
+ it under the terms of the GNU General Public License as published by
13
+ the Free Software Foundation, either version 3 of the License, or
14
+ (at your option) any later version.
15
+
16
+ This program is distributed in the hope that it will be useful, but
17
+ WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
19
+ General Public License for more details.
20
+
21
+ You should have received a copy of the GNU General Public License
22
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+
28
+ from time import time
29
+
30
+ from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot
31
+ from PyQt5.QtGui import QCursor, QKeyEvent
32
+ from PyQt5.QtWidgets import (
33
+ QApplication, QFrame, QHBoxLayout, QHeaderView, QLabel, QLineEdit,
34
+ QToolBar, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
35
+ )
36
+
37
+ from novelwriter import CONFIG, SHARED
38
+ from novelwriter.common import checkInt, cssCol
39
+ from novelwriter.core.coretools import DocSearch
40
+ from novelwriter.core.item import NWItem
41
+ from novelwriter.types import QtAlignMiddle, QtAlignRight, QtUserRole
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+
46
+ class GuiProjectSearch(QWidget):
47
+
48
+ C_NAME = 0
49
+ C_RESULT = 0
50
+ C_COUNT = 1
51
+
52
+ D_HANDLE = QtUserRole
53
+ D_RESULT = QtUserRole + 1
54
+
55
+ selectedItemChanged = pyqtSignal(str)
56
+ openDocumentSelectRequest = pyqtSignal(str, int, int, bool)
57
+
58
+ def __init__(self, parent: QWidget) -> None:
59
+ super().__init__(parent=parent)
60
+
61
+ logger.debug("Create: GuiProjectSearch")
62
+
63
+ iPx = SHARED.theme.baseIconHeight
64
+ iSz = SHARED.theme.baseIconSize
65
+ mPx = CONFIG.pxInt(2)
66
+ tPx = CONFIG.pxInt(4)
67
+
68
+ self._time = time()
69
+ self._search = DocSearch()
70
+ self._blocked = False
71
+ self._map: dict[str, tuple[int, float]] = {}
72
+
73
+ # Header
74
+ self.viewLabel = QLabel(self.tr("Project Search"), self)
75
+ self.viewLabel.setFont(SHARED.theme.guiFontB)
76
+ self.viewLabel.setContentsMargins(mPx, tPx, 0, mPx)
77
+
78
+ # Options
79
+ self.searchOpt = QToolBar(self)
80
+ self.searchOpt.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
81
+ self.searchOpt.setIconSize(iSz)
82
+ self.searchOpt.setContentsMargins(0, 0, 0, 0)
83
+
84
+ self.toggleCase = self.searchOpt.addAction(self.tr("Case Sensitive"))
85
+ self.toggleCase.setCheckable(True)
86
+ self.toggleCase.setChecked(CONFIG.searchProjCase)
87
+ self.toggleCase.toggled.connect(self._toggleCase)
88
+
89
+ self.toggleWord = self.searchOpt.addAction(self.tr("Whole Words Only"))
90
+ self.toggleWord.setCheckable(True)
91
+ self.toggleWord.setChecked(CONFIG.searchProjWord)
92
+ self.toggleWord.toggled.connect(self._toggleWord)
93
+
94
+ self.toggleRegEx = self.searchOpt.addAction(self.tr("RegEx Mode"))
95
+ self.toggleRegEx.setCheckable(True)
96
+ self.toggleRegEx.setChecked(CONFIG.searchProjRegEx)
97
+ self.toggleRegEx.toggled.connect(self._toggleRegEx)
98
+
99
+ # Search Box
100
+ self.searchText = QLineEdit(self)
101
+ self.searchText.setPlaceholderText(self.tr("Search for"))
102
+ self.searchText.setClearButtonEnabled(True)
103
+
104
+ self.searchAction = self.searchText.addAction(
105
+ SHARED.theme.getIcon("search"), QLineEdit.ActionPosition.TrailingPosition
106
+ )
107
+ self.searchAction.triggered.connect(self._processSearch)
108
+
109
+ # Search Result
110
+ self.searchResult = QTreeWidget(self)
111
+ self.searchResult.setHeaderHidden(True)
112
+ self.searchResult.setColumnCount(2)
113
+ self.searchResult.setIconSize(iSz)
114
+ self.searchResult.setIndentation(iPx)
115
+ self.searchResult.setFrameStyle(QFrame.Shape.NoFrame)
116
+ self.searchResult.setUniformRowHeights(True)
117
+ self.searchResult.setAllColumnsShowFocus(True)
118
+ self.searchResult.itemDoubleClicked.connect(self._searchResultDoubleClicked)
119
+ self.searchResult.itemSelectionChanged.connect(self._searchResultSelected)
120
+
121
+ treeHeader = self.searchResult.header()
122
+ treeHeader.setStretchLastSection(False)
123
+ treeHeader.setSectionResizeMode(self.C_NAME, QHeaderView.ResizeMode.Stretch)
124
+ treeHeader.setSectionResizeMode(self.C_COUNT, QHeaderView.ResizeMode.ResizeToContents)
125
+
126
+ # Assemble
127
+ self.headerBox = QHBoxLayout()
128
+ self.headerBox.addWidget(self.viewLabel, 1)
129
+ self.headerBox.addWidget(self.searchOpt, 0, QtAlignMiddle)
130
+ self.headerBox.setContentsMargins(0, 0, 0, 0)
131
+ self.headerBox.setSpacing(0)
132
+
133
+ self.headerWidget = QWidget(self)
134
+ self.headerWidget.setLayout(self.headerBox)
135
+ self.headerWidget.setContentsMargins(0, 0, 0, 0)
136
+
137
+ self.outerBox = QVBoxLayout()
138
+ self.outerBox.addWidget(self.headerWidget, 0)
139
+ self.outerBox.addWidget(self.searchText, 0)
140
+ self.outerBox.addWidget(self.searchResult, 1)
141
+ self.outerBox.setContentsMargins(0, 0, 0, 0)
142
+ self.outerBox.setSpacing(mPx)
143
+
144
+ self.setLayout(self.outerBox)
145
+ self.updateTheme()
146
+
147
+ logger.debug("Ready: GuiProjectSearch")
148
+
149
+ return
150
+
151
+ ##
152
+ # Methods
153
+ ##
154
+
155
+ def updateTheme(self) -> None:
156
+ """Update theme elements."""
157
+ bPx = CONFIG.pxInt(1)
158
+ mPx = CONFIG.pxInt(2)
159
+
160
+ qPalette = self.palette()
161
+ colBase = cssCol(qPalette.base().color())
162
+ colFocus = cssCol(qPalette.highlight().color())
163
+
164
+ self.headerWidget.setStyleSheet(f"QWidget {{background: {colBase};}}")
165
+ self.headerWidget.setAutoFillBackground(True)
166
+
167
+ self.setStyleSheet(
168
+ "QToolBar {padding: 0; background: none;} "
169
+ f"QLineEdit {{border: {bPx}px solid {colBase}; padding: {mPx}px;}} "
170
+ f"QLineEdit:focus {{border: {bPx}px solid {colFocus};}} "
171
+ )
172
+
173
+ self.searchAction.setIcon(SHARED.theme.getIcon("search"))
174
+ self.toggleCase.setIcon(SHARED.theme.getIcon("search_case"))
175
+ self.toggleWord.setIcon(SHARED.theme.getIcon("search_word"))
176
+ self.toggleRegEx.setIcon(SHARED.theme.getIcon("search_regex"))
177
+
178
+ return
179
+
180
+ def processReturn(self) -> None:
181
+ """Process a return keypress forwarded from the main GUI."""
182
+ if self.searchText.hasFocus():
183
+ self._processSearch()
184
+ elif (
185
+ self.searchResult.hasFocus()
186
+ and (items := self.searchResult.selectedItems())
187
+ and (data := items[0].data(0, self.D_RESULT))
188
+ and len(data) == 3
189
+ ):
190
+ self.openDocumentSelectRequest.emit(
191
+ str(data[0]), checkInt(data[1], -1), checkInt(data[2], -1), False
192
+ )
193
+ return
194
+
195
+ def beginSearch(self, text: str = "") -> None:
196
+ """Focus the search box and select its text, if any."""
197
+ self.searchText.setFocus()
198
+ self.searchText.selectAll()
199
+ if text:
200
+ self.searchText.setText(text.partition("\n")[0])
201
+ self.searchText.selectAll()
202
+ return
203
+
204
+ def closeProjectTasks(self) -> None:
205
+ """Run close project tasks."""
206
+ self._map = {}
207
+ self.searchText.clear()
208
+ self.searchResult.clear()
209
+ return
210
+
211
+ ##
212
+ # Events
213
+ ##
214
+
215
+ def keyPressEvent(self, event: QKeyEvent) -> None:
216
+ """Process key press events. This handles up and down arrow key
217
+ presses to jump between search text box and result tree.
218
+ """
219
+ if (
220
+ event.key() == Qt.Key.Key_Down
221
+ and self.searchText.hasFocus()
222
+ and (first := self.searchResult.topLevelItem(0))
223
+ ):
224
+ first.setSelected(True)
225
+ self.searchResult.setFocus()
226
+ elif (
227
+ event.key() == Qt.Key.Key_Up
228
+ and self.searchResult.hasFocus()
229
+ and (first := self.searchResult.topLevelItem(0))
230
+ and first.isSelected()
231
+ ):
232
+ first.setSelected(False)
233
+ self.searchText.setFocus()
234
+ else:
235
+ super().keyPressEvent(event)
236
+ return
237
+
238
+ ##
239
+ # Public Slots
240
+ ##
241
+
242
+ @pyqtSlot(str, float)
243
+ def textChanged(self, tHandle: str, timeStamp: float) -> None:
244
+ """Update search result for a specific document."""
245
+ if (entry := self._map.get(tHandle)) and timeStamp > entry[1]:
246
+ start = time()
247
+ results, capped = self._search.searchText(SHARED.mainGui.docEditor.getText())
248
+ self._displayResultSet(SHARED.project.tree[tHandle], results, capped)
249
+ logger.debug("Updated search for '%s' in %.3f ms", tHandle, 1000*(time() - start))
250
+ return
251
+
252
+ ##
253
+ # Private Slots
254
+ ##
255
+
256
+ @pyqtSlot()
257
+ def _processSearch(self) -> None:
258
+ """Perform a search."""
259
+ if not self._blocked:
260
+ QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor))
261
+ start = time()
262
+ SHARED.mainGui.saveDocument()
263
+ self._blocked = True
264
+ self._map = {}
265
+ self.searchResult.clear()
266
+ if text := self.searchText.text():
267
+ self._search.setUserRegEx(self.toggleRegEx.isChecked())
268
+ self._search.setCaseSensitive(self.toggleCase.isChecked())
269
+ self._search.setWholeWords(self.toggleWord.isChecked())
270
+ for item, results, capped in self._search.iterSearch(SHARED.project, text):
271
+ self._displayResultSet(item, results, capped)
272
+ logger.debug("Search took %.3f ms", 1000*(time() - start))
273
+ self._time = time()
274
+ QApplication.restoreOverrideCursor()
275
+ self._blocked = False
276
+ return
277
+
278
+ @pyqtSlot()
279
+ def _searchResultSelected(self) -> None:
280
+ """Process search result selection."""
281
+ if items := self.searchResult.selectedItems():
282
+ if (data := items[0].data(0, self.D_RESULT)) and len(data) == 3:
283
+ self.selectedItemChanged.emit(str(data[0]))
284
+ elif data := items[0].data(0, self.D_HANDLE):
285
+ self.selectedItemChanged.emit(str(data))
286
+ return
287
+
288
+ @pyqtSlot("QTreeWidgetItem*", int)
289
+ def _searchResultDoubleClicked(self, item: QTreeWidgetItem, column: int) -> None:
290
+ """Process search result double click."""
291
+ if (data := item.data(0, self.D_RESULT)) and len(data) == 3:
292
+ self.openDocumentSelectRequest.emit(
293
+ str(data[0]), checkInt(data[1], -1), checkInt(data[2], -1), True
294
+ )
295
+ return
296
+
297
+ @pyqtSlot(bool)
298
+ def _toggleCase(self, state: bool) -> None:
299
+ """Enable/disable case sensitive mode."""
300
+ CONFIG.searchProjCase = state
301
+ return
302
+
303
+ @pyqtSlot(bool)
304
+ def _toggleWord(self, state: bool) -> None:
305
+ """Enable/disable whole word search mode."""
306
+ CONFIG.searchProjWord = state
307
+ return
308
+
309
+ @pyqtSlot(bool)
310
+ def _toggleRegEx(self, state: bool) -> None:
311
+ """Enable/disable regular expression search mode."""
312
+ CONFIG.searchProjRegEx = state
313
+ return
314
+
315
+ ##
316
+ # Internal Functions
317
+ ##
318
+
319
+ def _displayResultSet(
320
+ self, nwItem: NWItem | None, results: list[tuple[int, int, str]], capped: bool
321
+ ) -> None:
322
+ """Populate the result tree."""
323
+ if results and nwItem:
324
+ tHandle = nwItem.itemHandle
325
+ docIcon = SHARED.theme.getItemIcon(
326
+ nwItem.itemType, nwItem.itemClass,
327
+ nwItem.itemLayout, nwItem.mainHeading
328
+ )
329
+ ext = "+" if capped else ""
330
+
331
+ tItem = QTreeWidgetItem()
332
+ tItem.setText(self.C_NAME, nwItem.itemName)
333
+ tItem.setIcon(self.C_NAME, docIcon)
334
+ tItem.setData(self.C_NAME, self.D_HANDLE, tHandle)
335
+ tItem.setText(self.C_COUNT, f"({len(results):n}{ext})")
336
+ tItem.setTextAlignment(self.C_COUNT, QtAlignRight)
337
+ tItem.setForeground(self.C_COUNT, self.palette().highlight())
338
+
339
+ index = self._map.get(tHandle, (self.searchResult.topLevelItemCount(), 0.0))[0]
340
+ self.searchResult.takeTopLevelItem(index)
341
+ self.searchResult.insertTopLevelItem(index, tItem)
342
+ self._map[tHandle] = (index, time())
343
+
344
+ rItems = []
345
+ for start, length, context in results:
346
+ rItem = QTreeWidgetItem()
347
+ rItem.setText(0, context)
348
+ rItem.setData(0, self.D_RESULT, (tHandle, start, length))
349
+ rItems.append(rItem)
350
+
351
+ tItem.addChildren(rItems)
352
+ tItem.setExpanded(True)
353
+
354
+ parent = self.searchResult.indexFromItem(tItem)
355
+ for i in range(tItem.childCount()):
356
+ self.searchResult.setFirstColumnSpanned(i, parent, True)
357
+
358
+ QApplication.processEvents()
359
+
360
+ return
361
+
362
+ # END Class GuiProjectSearch