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.
Files changed (114) hide show
  1. {novelWriter-2.6b1.dist-info → novelWriter-2.6rc1.dist-info}/METADATA +4 -4
  2. {novelWriter-2.6b1.dist-info → novelWriter-2.6rc1.dist-info}/RECORD +114 -98
  3. {novelWriter-2.6b1.dist-info → novelWriter-2.6rc1.dist-info}/WHEEL +1 -1
  4. novelwriter/__init__.py +50 -11
  5. novelwriter/assets/i18n/nw_de_DE.qm +0 -0
  6. novelwriter/assets/i18n/nw_en_US.qm +0 -0
  7. novelwriter/assets/i18n/nw_es_419.qm +0 -0
  8. novelwriter/assets/i18n/nw_fr_FR.qm +0 -0
  9. novelwriter/assets/i18n/nw_it_IT.qm +0 -0
  10. novelwriter/assets/i18n/nw_ja_JP.qm +0 -0
  11. novelwriter/assets/i18n/nw_nb_NO.qm +0 -0
  12. novelwriter/assets/i18n/nw_nl_NL.qm +0 -0
  13. novelwriter/assets/i18n/nw_pl_PL.qm +0 -0
  14. novelwriter/assets/i18n/nw_pt_BR.qm +0 -0
  15. novelwriter/assets/i18n/nw_ru_RU.qm +0 -0
  16. novelwriter/assets/i18n/nw_zh_CN.qm +0 -0
  17. novelwriter/assets/i18n/project_de_DE.json +2 -2
  18. novelwriter/assets/i18n/project_ru_RU.json +11 -0
  19. novelwriter/assets/icons/typicons_dark/icons.conf +7 -0
  20. novelwriter/assets/icons/typicons_dark/mixed_margin-bottom.svg +6 -0
  21. novelwriter/assets/icons/typicons_dark/mixed_margin-left.svg +6 -0
  22. novelwriter/assets/icons/typicons_dark/mixed_margin-right.svg +6 -0
  23. novelwriter/assets/icons/typicons_dark/mixed_margin-top.svg +6 -0
  24. novelwriter/assets/icons/typicons_dark/mixed_size-height.svg +6 -0
  25. novelwriter/assets/icons/typicons_dark/mixed_size-width.svg +6 -0
  26. novelwriter/assets/icons/typicons_dark/nw_toolbar.svg +5 -0
  27. novelwriter/assets/icons/typicons_light/icons.conf +7 -0
  28. novelwriter/assets/icons/typicons_light/mixed_margin-bottom.svg +6 -0
  29. novelwriter/assets/icons/typicons_light/mixed_margin-left.svg +6 -0
  30. novelwriter/assets/icons/typicons_light/mixed_margin-right.svg +6 -0
  31. novelwriter/assets/icons/typicons_light/mixed_margin-top.svg +6 -0
  32. novelwriter/assets/icons/typicons_light/mixed_size-height.svg +6 -0
  33. novelwriter/assets/icons/typicons_light/mixed_size-width.svg +6 -0
  34. novelwriter/assets/icons/typicons_light/nw_toolbar.svg +5 -0
  35. novelwriter/assets/manual.pdf +0 -0
  36. novelwriter/assets/sample.zip +0 -0
  37. novelwriter/assets/text/credits_en.htm +1 -0
  38. novelwriter/common.py +38 -3
  39. novelwriter/config.py +19 -13
  40. novelwriter/constants.py +60 -45
  41. novelwriter/core/buildsettings.py +1 -1
  42. novelwriter/core/coretools.py +112 -126
  43. novelwriter/core/docbuild.py +4 -3
  44. novelwriter/core/document.py +1 -1
  45. novelwriter/core/index.py +10 -20
  46. novelwriter/core/item.py +40 -7
  47. novelwriter/core/itemmodel.py +518 -0
  48. novelwriter/core/options.py +1 -1
  49. novelwriter/core/project.py +68 -90
  50. novelwriter/core/projectdata.py +8 -2
  51. novelwriter/core/projectxml.py +1 -1
  52. novelwriter/core/sessions.py +1 -1
  53. novelwriter/core/spellcheck.py +1 -1
  54. novelwriter/core/status.py +24 -8
  55. novelwriter/core/storage.py +1 -1
  56. novelwriter/core/tree.py +269 -288
  57. novelwriter/dialogs/about.py +1 -1
  58. novelwriter/dialogs/docmerge.py +8 -18
  59. novelwriter/dialogs/docsplit.py +1 -1
  60. novelwriter/dialogs/editlabel.py +1 -1
  61. novelwriter/dialogs/preferences.py +4 -4
  62. novelwriter/dialogs/projectsettings.py +148 -98
  63. novelwriter/dialogs/quotes.py +1 -1
  64. novelwriter/dialogs/wordlist.py +11 -10
  65. novelwriter/enum.py +8 -1
  66. novelwriter/error.py +2 -2
  67. novelwriter/extensions/configlayout.py +7 -5
  68. novelwriter/extensions/eventfilters.py +1 -1
  69. novelwriter/extensions/modified.py +17 -5
  70. novelwriter/extensions/novelselector.py +1 -1
  71. novelwriter/extensions/pagedsidebar.py +4 -4
  72. novelwriter/extensions/progressbars.py +4 -4
  73. novelwriter/extensions/statusled.py +3 -3
  74. novelwriter/extensions/switch.py +3 -3
  75. novelwriter/extensions/switchbox.py +1 -1
  76. novelwriter/extensions/versioninfo.py +1 -1
  77. novelwriter/formats/shared.py +1 -1
  78. novelwriter/formats/todocx.py +35 -39
  79. novelwriter/formats/tohtml.py +15 -16
  80. novelwriter/formats/tokenizer.py +26 -22
  81. novelwriter/formats/tomarkdown.py +1 -1
  82. novelwriter/formats/toodt.py +54 -125
  83. novelwriter/formats/toqdoc.py +93 -45
  84. novelwriter/formats/toraw.py +1 -1
  85. novelwriter/gui/doceditor.py +233 -220
  86. novelwriter/gui/dochighlight.py +1 -1
  87. novelwriter/gui/docviewer.py +39 -10
  88. novelwriter/gui/docviewerpanel.py +15 -23
  89. novelwriter/gui/editordocument.py +1 -1
  90. novelwriter/gui/itemdetails.py +20 -27
  91. novelwriter/gui/mainmenu.py +14 -9
  92. novelwriter/gui/noveltree.py +13 -13
  93. novelwriter/gui/outline.py +18 -20
  94. novelwriter/gui/projtree.py +545 -1201
  95. novelwriter/gui/search.py +11 -19
  96. novelwriter/gui/sidebar.py +1 -1
  97. novelwriter/gui/statusbar.py +20 -3
  98. novelwriter/gui/theme.py +8 -4
  99. novelwriter/guimain.py +60 -48
  100. novelwriter/shared.py +53 -24
  101. novelwriter/text/counting.py +1 -1
  102. novelwriter/text/patterns.py +18 -6
  103. novelwriter/tools/dictionaries.py +1 -1
  104. novelwriter/tools/lipsum.py +1 -1
  105. novelwriter/tools/manusbuild.py +14 -12
  106. novelwriter/tools/manuscript.py +7 -7
  107. novelwriter/tools/manussettings.py +43 -53
  108. novelwriter/tools/noveldetails.py +1 -1
  109. novelwriter/tools/welcome.py +1 -1
  110. novelwriter/tools/writingstats.py +1 -1
  111. novelwriter/types.py +9 -3
  112. {novelWriter-2.6b1.dist-info → novelWriter-2.6rc1.dist-info}/LICENSE.md +0 -0
  113. {novelWriter-2.6b1.dist-info → novelWriter-2.6rc1.dist-info}/entry_points.txt +0 -0
  114. {novelWriter-2.6b1.dist-info → novelWriter-2.6rc1.dist-info}/top_level.txt +0 -0
@@ -14,7 +14,7 @@ Created: 2023-11-06 [2.2b1] MetaCompleter
14
14
  Created: 2023-11-07 [2.2b1] GuiDocToolBar
15
15
 
16
16
  This file is a part of novelWriter
17
- Copyright 2018–2024, Veronica Berglyd Olsen
17
+ Copyright (C) 2018 Veronica Berglyd Olsen and novelWriter contributors
18
18
 
19
19
  This program is free software: you can redistribute it and/or modify
20
20
  it under the terms of the GNU General Public License as published by
@@ -36,15 +36,15 @@ import logging
36
36
 
37
37
  from enum import Enum
38
38
  from time import time
39
- from typing import NamedTuple
40
39
 
41
40
  from PyQt5.QtCore import (
42
41
  QObject, QPoint, QRegularExpression, QRunnable, Qt, QTimer, pyqtSignal,
43
42
  pyqtSlot
44
43
  )
45
44
  from PyQt5.QtGui import (
46
- QColor, QCursor, QKeyEvent, QKeySequence, QMouseEvent, QPalette, QPixmap,
47
- QResizeEvent, QTextBlock, QTextCursor, QTextDocument, QTextOption
45
+ QColor, QCursor, QDragEnterEvent, QDragMoveEvent, QDropEvent, QKeyEvent,
46
+ QKeySequence, QMouseEvent, QPalette, QPixmap, QResizeEvent, QTextBlock,
47
+ QTextCursor, QTextDocument, QTextOption
48
48
  )
49
49
  from PyQt5.QtWidgets import (
50
50
  QAction, QApplication, QFrame, QGridLayout, QHBoxLayout, QLabel, QLineEdit,
@@ -52,12 +52,12 @@ from PyQt5.QtWidgets import (
52
52
  )
53
53
 
54
54
  from novelwriter import CONFIG, SHARED
55
- from novelwriter.common import minmax, qtLambda, transferCase
55
+ from novelwriter.common import decodeMimeHandles, fontMatcher, minmax, qtLambda, transferCase
56
56
  from novelwriter.constants import nwConst, nwKeyWords, nwShortcode, nwUnicode
57
57
  from novelwriter.core.document import NWDocument
58
58
  from novelwriter.enum import (
59
- nwComment, nwDocAction, nwDocInsert, nwDocMode, nwItemClass, nwItemType,
60
- nwTrinary
59
+ nwChange, nwComment, nwDocAction, nwDocInsert, nwDocMode, nwItemClass,
60
+ nwItemType, nwTrinary
61
61
  )
62
62
  from novelwriter.extensions.configlayout import NColourLabel
63
63
  from novelwriter.extensions.eventfilters import WheelEventFilter
@@ -84,24 +84,16 @@ class _SelectAction(Enum):
84
84
  MOVE_AFTER = 3
85
85
 
86
86
 
87
- class AutoReplaceConfig(NamedTuple):
88
-
89
- typPadChar: str
90
- typSQuoteO: str
91
- typSQuoteC: str
92
- typDQuoteO: str
93
- typDQuoteC: str
94
- typRepDQuote: bool
95
- typRepSQuote: bool
96
- typRepDash: bool
97
- typRepDots: bool
98
- typPadBefore: str
99
- typPadAfter: str
100
-
101
-
102
87
  class GuiDocEditor(QPlainTextEdit):
103
88
  """Gui Widget: Main Document Editor"""
104
89
 
90
+ __slots__ = (
91
+ "_nwDocument", "_nwItem", "_docChanged", "_docHandle", "_vpMargin",
92
+ "_lastEdit", "_lastActive", "_lastFind", "_doReplace", "_autoReplace",
93
+ "_completer", "_qDocument", "_keyContext", "_followTag1", "_followTag2",
94
+ "_timerDoc", "_wCounterDoc", "_timerSel", "_wCounterSel",
95
+ )
96
+
105
97
  MOVE_KEYS = (
106
98
  Qt.Key.Key_Left, Qt.Key.Key_Right, Qt.Key.Key_Up, Qt.Key.Key_Down,
107
99
  Qt.Key.Key_PageUp, Qt.Key.Key_PageDown
@@ -109,13 +101,13 @@ class GuiDocEditor(QPlainTextEdit):
109
101
 
110
102
  # Custom Signals
111
103
  closeEditorRequest = pyqtSignal()
112
- docCountsChanged = pyqtSignal(str, int, int, int)
113
104
  docTextChanged = pyqtSignal(str, float)
114
105
  editedStatusChanged = pyqtSignal(bool)
115
106
  itemHandleChanged = pyqtSignal(str)
116
107
  loadDocumentTagRequest = pyqtSignal(str, Enum)
117
108
  novelItemMetaChanged = pyqtSignal(str)
118
109
  novelStructureChanged = pyqtSignal()
110
+ openDocumentRequest = pyqtSignal(str, Enum, str, bool)
119
111
  requestNewNoteCreation = pyqtSignal(str, nwItemClass)
120
112
  requestNextDocument = pyqtSignal(str, bool)
121
113
  requestProjectItemRenamed = pyqtSignal(str, str)
@@ -143,20 +135,8 @@ class GuiDocEditor(QPlainTextEdit):
143
135
  self._lastFind = None # Position of the last found search word
144
136
  self._doReplace = False # Switch to temporarily disable auto-replace
145
137
 
146
- # Typography Cache
147
- self._typConf = AutoReplaceConfig(
148
- typPadChar=" ",
149
- typSQuoteO="'",
150
- typSQuoteC="'",
151
- typDQuoteO='"',
152
- typDQuoteC='"',
153
- typRepSQuote=False,
154
- typRepDQuote=False,
155
- typRepDash=False,
156
- typRepDots=False,
157
- typPadBefore="",
158
- typPadAfter="",
159
- )
138
+ # Auto-Replace
139
+ self._autoReplace = TextAutoReplace()
160
140
 
161
141
  # Completer
162
142
  self._completer = MetaCompleter(self)
@@ -191,40 +171,41 @@ class GuiDocEditor(QPlainTextEdit):
191
171
  self.setMinimumWidth(CONFIG.pxInt(300))
192
172
  self.setAutoFillBackground(True)
193
173
  self.setFrameStyle(QFrame.Shape.NoFrame)
174
+ self.setAcceptDrops(True)
194
175
 
195
176
  # Custom Shortcuts
196
- self.keyContext = QShortcut(self)
197
- self.keyContext.setKey("Ctrl+.")
198
- self.keyContext.setContext(Qt.ShortcutContext.WidgetShortcut)
199
- self.keyContext.activated.connect(self._openContextFromCursor)
177
+ self._keyContext = QShortcut(self)
178
+ self._keyContext.setKey("Ctrl+.")
179
+ self._keyContext.setContext(Qt.ShortcutContext.WidgetShortcut)
180
+ self._keyContext.activated.connect(self._openContextFromCursor)
200
181
 
201
- self.followTag1 = QShortcut(self)
202
- self.followTag1.setKey("Ctrl+Return")
203
- self.followTag1.setContext(Qt.ShortcutContext.WidgetShortcut)
204
- self.followTag1.activated.connect(self._processTag)
182
+ self._followTag1 = QShortcut(self)
183
+ self._followTag1.setKey("Ctrl+Return")
184
+ self._followTag1.setContext(Qt.ShortcutContext.WidgetShortcut)
185
+ self._followTag1.activated.connect(self._processTag)
205
186
 
206
- self.followTag2 = QShortcut(self)
207
- self.followTag2.setKey("Ctrl+Enter")
208
- self.followTag2.setContext(Qt.ShortcutContext.WidgetShortcut)
209
- self.followTag2.activated.connect(self._processTag)
187
+ self._followTag2 = QShortcut(self)
188
+ self._followTag2.setKey("Ctrl+Enter")
189
+ self._followTag2.setContext(Qt.ShortcutContext.WidgetShortcut)
190
+ self._followTag2.activated.connect(self._processTag)
210
191
 
211
192
  # Set Up Document Word Counter
212
- self.timerDoc = QTimer(self)
213
- self.timerDoc.timeout.connect(self._runDocumentTasks)
214
- self.timerDoc.setInterval(5000)
193
+ self._timerDoc = QTimer(self)
194
+ self._timerDoc.timeout.connect(self._runDocumentTasks)
195
+ self._timerDoc.setInterval(5000)
215
196
 
216
- self.wCounterDoc = BackgroundWordCounter(self)
217
- self.wCounterDoc.setAutoDelete(False)
218
- self.wCounterDoc.signals.countsReady.connect(self._updateDocCounts)
197
+ self._wCounterDoc = BackgroundWordCounter(self)
198
+ self._wCounterDoc.setAutoDelete(False)
199
+ self._wCounterDoc.signals.countsReady.connect(self._updateDocCounts)
219
200
 
220
201
  # Set Up Selection Word Counter
221
- self.timerSel = QTimer(self)
222
- self.timerSel.timeout.connect(self._runSelCounter)
223
- self.timerSel.setInterval(500)
202
+ self._timerSel = QTimer(self)
203
+ self._timerSel.timeout.connect(self._runSelCounter)
204
+ self._timerSel.setInterval(500)
224
205
 
225
- self.wCounterSel = BackgroundWordCounter(self, forSelection=True)
226
- self.wCounterSel.setAutoDelete(False)
227
- self.wCounterSel.signals.countsReady.connect(self._updateSelCounts)
206
+ self._wCounterSel = BackgroundWordCounter(self, forSelection=True)
207
+ self._wCounterSel.setAutoDelete(False)
208
+ self._wCounterSel.signals.countsReady.connect(self._updateSelCounts)
228
209
 
229
210
  # Install Event Filter for Mouse Wheel
230
211
  self.wheelEventFilter = WheelEventFilter(self)
@@ -278,8 +259,8 @@ class GuiDocEditor(QPlainTextEdit):
278
259
  self._nwDocument = None
279
260
  self.setReadOnly(True)
280
261
  self.clear()
281
- self.timerDoc.stop()
282
- self.timerSel.stop()
262
+ self._timerDoc.stop()
263
+ self._timerSel.stop()
283
264
 
284
265
  self._docHandle = None
285
266
  self._lastEdit = 0.0
@@ -327,26 +308,16 @@ class GuiDocEditor(QPlainTextEdit):
327
308
  settings. This function is both called when the editor is
328
309
  created, and when the user changes the main editor preferences.
329
310
  """
330
- # Typography
331
- self._typConf = AutoReplaceConfig(
332
- typPadChar=nwUnicode.U_THNBSP if CONFIG.fmtPadThin else nwUnicode.U_NBSP,
333
- typSQuoteO=CONFIG.fmtSQuoteOpen,
334
- typSQuoteC=CONFIG.fmtSQuoteClose,
335
- typDQuoteO=CONFIG.fmtDQuoteOpen,
336
- typDQuoteC=CONFIG.fmtDQuoteClose,
337
- typRepSQuote=CONFIG.doReplaceSQuote,
338
- typRepDQuote=CONFIG.doReplaceDQuote,
339
- typRepDash=CONFIG.doReplaceDash,
340
- typRepDots=CONFIG.doReplaceDots,
341
- typPadBefore=CONFIG.fmtPadBefore,
342
- typPadAfter=CONFIG.fmtPadAfter,
343
- )
311
+ # Auto-Replace
312
+ self._autoReplace.initSettings()
344
313
 
345
314
  # Reload spell check and dictionaries
346
315
  SHARED.updateSpellCheckLanguage()
347
316
 
348
317
  # Set the font. See issues #1862 and #1875.
349
- self.setFont(CONFIG.textFont)
318
+ font = fontMatcher(CONFIG.textFont)
319
+ self.setFont(font)
320
+ self._qDocument.setDefaultFont(font)
350
321
  self.docHeader.updateFont()
351
322
  self.docFooter.updateFont()
352
323
  self.docSearch.updateFont()
@@ -431,7 +402,7 @@ class GuiDocEditor(QPlainTextEdit):
431
402
  self._lastEdit = time()
432
403
  self._lastActive = time()
433
404
  self._runDocumentTasks()
434
- self.timerDoc.start()
405
+ self._timerDoc.start()
435
406
 
436
407
  self.setReadOnly(False)
437
408
  self.updateDocMargins()
@@ -753,7 +724,6 @@ class GuiDocEditor(QPlainTextEdit):
753
724
 
754
725
  logger.debug("Requesting action: %s", action.name)
755
726
 
756
- tConf = self._typConf
757
727
  self._allowAutoReplace(False)
758
728
  if action == nwDocAction.UNDO:
759
729
  self.undo()
@@ -772,9 +742,9 @@ class GuiDocEditor(QPlainTextEdit):
772
742
  elif action == nwDocAction.MD_STRIKE:
773
743
  self._toggleFormat(2, "~")
774
744
  elif action == nwDocAction.S_QUOTE:
775
- self._wrapSelection(tConf.typSQuoteO, tConf.typSQuoteC)
745
+ self._wrapSelection(CONFIG.fmtSQuoteOpen, CONFIG.fmtSQuoteClose)
776
746
  elif action == nwDocAction.D_QUOTE:
777
- self._wrapSelection(tConf.typDQuoteO, tConf.typDQuoteC)
747
+ self._wrapSelection(CONFIG.fmtDQuoteOpen, CONFIG.fmtDQuoteClose)
778
748
  elif action == nwDocAction.SEL_ALL:
779
749
  self._makeSelection(QTextCursor.SelectionType.Document)
780
750
  elif action == nwDocAction.SEL_PARA:
@@ -800,9 +770,9 @@ class GuiDocEditor(QPlainTextEdit):
800
770
  elif action == nwDocAction.BLOCK_HSC:
801
771
  self._formatBlock(nwDocAction.BLOCK_HSC)
802
772
  elif action == nwDocAction.REPL_SNG:
803
- self._replaceQuotes("'", tConf.typSQuoteO, tConf.typSQuoteC)
773
+ self._replaceQuotes("'", CONFIG.fmtSQuoteOpen, CONFIG.fmtSQuoteClose)
804
774
  elif action == nwDocAction.REPL_DBL:
805
- self._replaceQuotes("\"", tConf.typDQuoteO, tConf.typDQuoteC)
775
+ self._replaceQuotes("\"", CONFIG.fmtDQuoteOpen, CONFIG.fmtDQuoteClose)
806
776
  elif action == nwDocAction.RM_BREAKS:
807
777
  self._removeInParLineBreaks()
808
778
  elif action == nwDocAction.ALIGN_L:
@@ -874,13 +844,13 @@ class GuiDocEditor(QPlainTextEdit):
874
844
  text = insert
875
845
  elif isinstance(insert, nwDocInsert):
876
846
  if insert == nwDocInsert.QUOTE_LS:
877
- text = self._typConf.typSQuoteO
847
+ text = CONFIG.fmtSQuoteOpen
878
848
  elif insert == nwDocInsert.QUOTE_RS:
879
- text = self._typConf.typSQuoteC
849
+ text = CONFIG.fmtSQuoteClose
880
850
  elif insert == nwDocInsert.QUOTE_LD:
881
- text = self._typConf.typDQuoteO
851
+ text = CONFIG.fmtDQuoteOpen
882
852
  elif insert == nwDocInsert.QUOTE_RD:
883
- text = self._typConf.typDQuoteC
853
+ text = CONFIG.fmtDQuoteClose
884
854
  elif insert == nwDocInsert.SYNOPSIS:
885
855
  text = "%Synopsis: "
886
856
  block = True
@@ -997,6 +967,32 @@ class GuiDocEditor(QPlainTextEdit):
997
967
 
998
968
  return
999
969
 
970
+ def dragEnterEvent(self, event: QDragEnterEvent) -> None:
971
+ """Overload drag enter event to handle dragged items."""
972
+ if event.mimeData().hasFormat(nwConst.MIME_HANDLE):
973
+ event.acceptProposedAction()
974
+ else:
975
+ super().dragEnterEvent(event)
976
+ return
977
+
978
+ def dragMoveEvent(self, event: QDragMoveEvent) -> None:
979
+ """Overload drag move event to handle dragged items."""
980
+ if event.mimeData().hasFormat(nwConst.MIME_HANDLE):
981
+ event.acceptProposedAction()
982
+ else:
983
+ super().dragMoveEvent(event)
984
+ return
985
+
986
+ def dropEvent(self, event: QDropEvent) -> None:
987
+ """Overload drop event to handle dragged items."""
988
+ if event.mimeData().hasFormat(nwConst.MIME_HANDLE):
989
+ if handles := decodeMimeHandles(event.mimeData()):
990
+ if SHARED.project.tree.checkType(handles[0], nwItemType.FILE):
991
+ self.openDocumentRequest.emit(handles[0], nwDocMode.EDIT, "", True)
992
+ else:
993
+ super().dropEvent(event)
994
+ return
995
+
1000
996
  def focusNextPrevChild(self, next: bool) -> bool:
1001
997
  """Capture the focus request from the tab key on the text
1002
998
  editor. If the editor has focus, we do not change focus and
@@ -1036,12 +1032,12 @@ class GuiDocEditor(QPlainTextEdit):
1036
1032
  # Public Slots
1037
1033
  ##
1038
1034
 
1039
- @pyqtSlot(str)
1040
- def updateDocInfo(self, tHandle: str) -> None:
1035
+ @pyqtSlot(str, Enum)
1036
+ def onProjectItemChanged(self, tHandle: str, change: nwChange) -> None:
1041
1037
  """Called when an item label is changed to check if the document
1042
1038
  title bar needs updating,
1043
1039
  """
1044
- if tHandle and tHandle == self._docHandle:
1040
+ if tHandle == self._docHandle and change == nwChange.UPDATE:
1045
1041
  self.docHeader.setHandle(tHandle)
1046
1042
  self.docFooter.updateInfo()
1047
1043
  self.updateDocMargins()
@@ -1090,8 +1086,8 @@ class GuiDocEditor(QPlainTextEdit):
1090
1086
  if not self._docChanged:
1091
1087
  self.setDocumentChanged(removed != 0 or added != 0)
1092
1088
 
1093
- if not self.timerDoc.isActive():
1094
- self.timerDoc.start()
1089
+ if not self._timerDoc.isActive():
1090
+ self._timerDoc.start()
1095
1091
 
1096
1092
  if (block := self._qDocument.findBlock(pos)).isValid():
1097
1093
  text = block.text()
@@ -1109,7 +1105,9 @@ class GuiDocEditor(QPlainTextEdit):
1109
1105
  self._completer.setVisible(False)
1110
1106
 
1111
1107
  if self._doReplace and added == 1:
1112
- self._docAutoReplace(text)
1108
+ cursor = self.textCursor()
1109
+ if self._autoReplace.process(text, cursor):
1110
+ self._qDocument.syntaxHighlighter.rehighlightBlock(cursor.block())
1113
1111
 
1114
1112
  return
1115
1113
 
@@ -1231,8 +1229,8 @@ class GuiDocEditor(QPlainTextEdit):
1231
1229
 
1232
1230
  if time() - self._lastEdit < 25.0:
1233
1231
  logger.debug("Running document tasks")
1234
- if not self.wCounterDoc.isRunning():
1235
- SHARED.runInThreadPool(self.wCounterDoc)
1232
+ if not self._wCounterDoc.isRunning():
1233
+ SHARED.runInThreadPool(self._wCounterDoc)
1236
1234
 
1237
1235
  self.docHeader.setOutline({
1238
1236
  block.blockNumber(): block.text()
@@ -1249,11 +1247,15 @@ class GuiDocEditor(QPlainTextEdit):
1249
1247
  """Process the word counter's finished signal."""
1250
1248
  if self._docHandle and self._nwItem:
1251
1249
  logger.debug("Updating word count")
1250
+ needsRefresh = wCount != self._nwItem.wordCount
1252
1251
  self._nwItem.setCharCount(cCount)
1253
1252
  self._nwItem.setWordCount(wCount)
1254
1253
  self._nwItem.setParaCount(pCount)
1255
- self.docCountsChanged.emit(self._docHandle, cCount, wCount, pCount)
1256
- self.docFooter.updateWordCount(wCount, False)
1254
+ if needsRefresh:
1255
+ self._nwItem.notifyToRefresh()
1256
+ if not self.textCursor().hasSelection():
1257
+ # Selection counter should take precedence (#2155)
1258
+ self.docFooter.updateWordCount(wCount, False)
1257
1259
  return
1258
1260
 
1259
1261
  @pyqtSlot()
@@ -1262,10 +1264,10 @@ class GuiDocEditor(QPlainTextEdit):
1262
1264
  information to the footer, and start the selection word counter.
1263
1265
  """
1264
1266
  if self.textCursor().hasSelection():
1265
- if not self.timerSel.isActive():
1266
- self.timerSel.start()
1267
+ if not self._timerSel.isActive():
1268
+ self._timerSel.start()
1267
1269
  else:
1268
- self.timerSel.stop()
1270
+ self._timerSel.stop()
1269
1271
  self.docFooter.updateWordCount(0, False)
1270
1272
  return
1271
1273
 
@@ -1275,11 +1277,11 @@ class GuiDocEditor(QPlainTextEdit):
1275
1277
  if self._docHandle is None:
1276
1278
  return
1277
1279
 
1278
- if self.wCounterSel.isRunning():
1280
+ if self._wCounterSel.isRunning():
1279
1281
  logger.debug("Selection word counter is busy")
1280
1282
  return
1281
1283
 
1282
- SHARED.runInThreadPool(self.wCounterSel)
1284
+ SHARED.runInThreadPool(self._wCounterSel)
1283
1285
 
1284
1286
  return
1285
1287
 
@@ -1289,7 +1291,7 @@ class GuiDocEditor(QPlainTextEdit):
1289
1291
  if self._docHandle and self._nwItem:
1290
1292
  logger.debug("User selected %d words", wCount)
1291
1293
  self.docFooter.updateWordCount(wCount, True)
1292
- self.timerSel.stop()
1294
+ self._timerSel.stop()
1293
1295
  return
1294
1296
 
1295
1297
  @pyqtSlot()
@@ -1986,120 +1988,6 @@ class GuiDocEditor(QPlainTextEdit):
1986
1988
  self.requestProjectItemRenamed.emit(self._docHandle, text)
1987
1989
  return
1988
1990
 
1989
- def _docAutoReplace(self, text: str) -> None:
1990
- """Auto-replace text elements based on main configuration."""
1991
- cursor = self.textCursor()
1992
- tPos = cursor.positionInBlock()
1993
- tLen = len(text)
1994
-
1995
- if tLen < 1 or tPos-1 > tLen:
1996
- return
1997
-
1998
- t1 = text[tPos-1:tPos]
1999
- t2 = text[tPos-2:tPos]
2000
- t3 = text[tPos-3:tPos]
2001
- t4 = text[tPos-4:tPos]
2002
-
2003
- if not t1:
2004
- return
2005
-
2006
- delete = 0
2007
- insert = t1
2008
- tConf = self._typConf
2009
-
2010
- if tConf.typRepDQuote and t2[:1].isspace() and t2.endswith('"'):
2011
- delete = 1
2012
- insert = tConf.typDQuoteO
2013
-
2014
- elif tConf.typRepDQuote and t1 == '"':
2015
- delete = 1
2016
- if tPos == 1:
2017
- insert = tConf.typDQuoteO
2018
- elif tPos == 2 and t2 == '>"':
2019
- insert = tConf.typDQuoteO
2020
- elif tPos == 3 and t3 == '>>"':
2021
- insert = tConf.typDQuoteO
2022
- else:
2023
- insert = tConf.typDQuoteC
2024
-
2025
- elif tConf.typRepSQuote and t2[:1].isspace() and t2.endswith("'"):
2026
- delete = 1
2027
- insert = tConf.typSQuoteO
2028
-
2029
- elif tConf.typRepSQuote and t1 == "'":
2030
- delete = 1
2031
- if tPos == 1:
2032
- insert = tConf.typSQuoteO
2033
- elif tPos == 2 and t2 == ">'":
2034
- insert = tConf.typSQuoteO
2035
- elif tPos == 3 and t3 == ">>'":
2036
- insert = tConf.typSQuoteO
2037
- else:
2038
- insert = tConf.typSQuoteC
2039
-
2040
- elif tConf.typRepDash and t4 == "----":
2041
- delete = 4
2042
- insert = nwUnicode.U_HBAR
2043
-
2044
- elif tConf.typRepDash and t3 == "---":
2045
- delete = 3
2046
- insert = nwUnicode.U_EMDASH
2047
-
2048
- elif tConf.typRepDash and t2 == "--":
2049
- delete = 2
2050
- insert = nwUnicode.U_ENDASH
2051
-
2052
- elif tConf.typRepDash and t2 == nwUnicode.U_ENDASH + "-":
2053
- delete = 2
2054
- insert = nwUnicode.U_EMDASH
2055
-
2056
- elif tConf.typRepDash and t2 == nwUnicode.U_EMDASH + "-":
2057
- delete = 2
2058
- insert = nwUnicode.U_HBAR
2059
-
2060
- elif tConf.typRepDots and t3 == "...":
2061
- delete = 3
2062
- insert = nwUnicode.U_HELLIP
2063
-
2064
- elif t1 == nwUnicode.U_LSEP:
2065
- # This resolves issue #1150
2066
- delete = 1
2067
- insert = nwUnicode.U_PSEP
2068
-
2069
- check = insert
2070
- if tConf.typPadBefore and check in tConf.typPadBefore:
2071
- if self._allowSpaceBeforeColon(text, check):
2072
- delete = max(delete, 1)
2073
- chkPos = tPos - delete - 1
2074
- if chkPos >= 0 and text[chkPos].isspace():
2075
- # Strip existing space before inserting a new (#1061)
2076
- delete += 1
2077
- insert = tConf.typPadChar + insert
2078
-
2079
- if tConf.typPadAfter and check in tConf.typPadAfter:
2080
- if self._allowSpaceBeforeColon(text, check):
2081
- delete = max(delete, 1)
2082
- insert = insert + tConf.typPadChar
2083
-
2084
- if delete > 0:
2085
- cursor.movePosition(QtMoveLeft, QtKeepAnchor, delete)
2086
- cursor.insertText(insert)
2087
-
2088
- # Re-highlight, since the auto-replace sometimes interferes with it
2089
- self._qDocument.syntaxHighlighter.rehighlightBlock(cursor.block())
2090
-
2091
- return
2092
-
2093
- @staticmethod
2094
- def _allowSpaceBeforeColon(text: str, char: str) -> bool:
2095
- """Special checker function only used by the insert space
2096
- feature for French, Spanish, etc, so it doesn't insert a
2097
- space before colons in meta data lines. See issue #1090.
2098
- """
2099
- if char == ":" and len(text) > 1 and text[0] == "@":
2100
- return False
2101
- return True
2102
-
2103
1991
  def _autoSelect(self) -> QTextCursor:
2104
1992
  """Return a cursor which may or may not have a selection based
2105
1993
  on user settings and document action. The selection will be the
@@ -2308,6 +2196,131 @@ class BackgroundWordCounterSignals(QObject):
2308
2196
  countsReady = pyqtSignal(int, int, int)
2309
2197
 
2310
2198
 
2199
+ class TextAutoReplace:
2200
+
2201
+ __slots__ = (
2202
+ "_quoteSO", "_quoteSC", "_quoteDO", "_quoteDC",
2203
+ "_replaceSQuote", "_replaceDQuote", "_replaceDash", "_replaceDots",
2204
+ "_padChar", "_padBefore", "_padAfter", "_doPadBefore", "_doPadAfter",
2205
+ )
2206
+
2207
+ def __init__(self) -> None:
2208
+ self.initSettings()
2209
+ return
2210
+
2211
+ def initSettings(self) -> None:
2212
+ """Initialise the auto-replace settings from config."""
2213
+ self._quoteSO = CONFIG.fmtSQuoteOpen
2214
+ self._quoteSC = CONFIG.fmtSQuoteClose
2215
+ self._quoteDO = CONFIG.fmtDQuoteOpen
2216
+ self._quoteDC = CONFIG.fmtDQuoteClose
2217
+
2218
+ self._replaceSQuote = CONFIG.doReplaceSQuote
2219
+ self._replaceDQuote = CONFIG.doReplaceDQuote
2220
+ self._replaceDash = CONFIG.doReplaceDash
2221
+ self._replaceDots = CONFIG.doReplaceDots
2222
+
2223
+ self._padChar = nwUnicode.U_THNBSP if CONFIG.fmtPadThin else nwUnicode.U_NBSP
2224
+ self._padBefore = CONFIG.fmtPadBefore
2225
+ self._padAfter = CONFIG.fmtPadAfter
2226
+ self._doPadBefore = bool(CONFIG.fmtPadBefore)
2227
+ self._doPadAfter = bool(CONFIG.fmtPadAfter)
2228
+ return
2229
+
2230
+ def process(self, text: str, cursor: QTextCursor) -> bool:
2231
+ """Auto-replace text elements based on main configuration.
2232
+ Returns True if anything was changed.
2233
+ """
2234
+ pos = cursor.positionInBlock()
2235
+ length = len(text)
2236
+ if length < 1 or pos-1 > length:
2237
+ return False
2238
+
2239
+ delete, insert = self._determine(text, pos)
2240
+ if insert == "":
2241
+ return False
2242
+
2243
+ check = insert
2244
+ if self._doPadBefore and check in self._padBefore:
2245
+ if not (check == ":" and length > 1 and text[0] == "@"):
2246
+ delete = max(delete, 1)
2247
+ chkPos = pos - delete - 1
2248
+ if chkPos >= 0 and text[chkPos].isspace():
2249
+ # Strip existing space before inserting a new (#1061)
2250
+ delete += 1
2251
+ insert = self._padChar + insert
2252
+
2253
+ if self._doPadAfter and check in self._padAfter:
2254
+ if not (check == ":" and length > 1 and text[0] == "@"):
2255
+ delete = max(delete, 1)
2256
+ insert = insert + self._padChar
2257
+
2258
+ if delete > 0:
2259
+ cursor.movePosition(QtMoveLeft, QtKeepAnchor, delete)
2260
+ cursor.insertText(insert)
2261
+ return True
2262
+
2263
+ return False
2264
+
2265
+ def _determine(self, text: str, pos: int) -> tuple[int, str]:
2266
+ """Determine what to replace, if anything."""
2267
+ t1 = text[pos-1:pos]
2268
+ t2 = text[pos-2:pos]
2269
+ t3 = text[pos-3:pos]
2270
+ t4 = text[pos-4:pos]
2271
+ if t1 == "":
2272
+ # Return early if there is nothing to check
2273
+ return 0, ""
2274
+
2275
+ leading = t2[:1].isspace()
2276
+ if self._replaceDQuote:
2277
+ if leading and t2.endswith('"'):
2278
+ return 1, self._quoteDO
2279
+ elif t1 == '"':
2280
+ if pos == 1:
2281
+ return 1, self._quoteDO
2282
+ elif pos == 2 and t2 == '>"':
2283
+ return 1, self._quoteDO
2284
+ elif pos == 3 and t3 == '>>"':
2285
+ return 1, self._quoteDO
2286
+ else:
2287
+ return 1, self._quoteDC
2288
+
2289
+ if self._replaceSQuote:
2290
+ if leading and t2.endswith("'"):
2291
+ return 1, self._quoteSO
2292
+ elif t1 == "'":
2293
+ if pos == 1:
2294
+ return 1, self._quoteSO
2295
+ elif pos == 2 and t2 == ">'":
2296
+ return 1, self._quoteSO
2297
+ elif pos == 3 and t3 == ">>'":
2298
+ return 1, self._quoteSO
2299
+ else:
2300
+ return 1, self._quoteSC
2301
+
2302
+ if self._replaceDash:
2303
+ if t4 == "----":
2304
+ return 4, "\u2015" # Horizontal bar
2305
+ elif t3 == "---":
2306
+ return 3, "\u2014" # Long dash
2307
+ elif t2 == "--":
2308
+ return 2, "\u2013" # Short dash
2309
+ elif t2 == "\u2013-":
2310
+ return 2, "\u2014" # Long dash
2311
+ elif t2 == "\u2014-":
2312
+ return 2, "\u2015" # Horizontal bar
2313
+
2314
+ if self._replaceDots and t3 == "...":
2315
+ return 3, "\u2026" # Ellipsis
2316
+
2317
+ if t1 == "\u2028": # Line separator
2318
+ # This resolves issue #1150
2319
+ return 1, "\u2029" # Paragraph separator
2320
+
2321
+ return 0, t1
2322
+
2323
+
2311
2324
  class GuiDocToolBar(QWidget):
2312
2325
  """The Formatting and Options Fold Out Menu
2313
2326
 
@@ -2941,7 +2954,7 @@ class GuiDocEditHeader(QWidget):
2941
2954
 
2942
2955
  def updateTheme(self) -> None:
2943
2956
  """Update theme elements."""
2944
- self.tbButton.setThemeIcon("menu")
2957
+ self.tbButton.setThemeIcon("toolbar")
2945
2958
  self.outlineButton.setThemeIcon("list")
2946
2959
  self.searchButton.setThemeIcon("search")
2947
2960
  self.minmaxButton.setThemeIcon("maximise")
@@ -2985,7 +2998,7 @@ class GuiDocEditHeader(QWidget):
2985
2998
 
2986
2999
  if CONFIG.showFullPath:
2987
3000
  self.itemTitle.setText(f" {nwUnicode.U_RSAQUO} ".join(reversed(
2988
- [name for name in SHARED.project.tree.getItemPath(tHandle, asName=True)]
3001
+ [name for name in SHARED.project.tree.itemPath(tHandle, asName=True)]
2989
3002
  )))
2990
3003
  else:
2991
3004
  self.itemTitle.setText(i.itemName if (i := SHARED.project.tree[tHandle]) else "")
@@ -7,7 +7,7 @@ Created: 2019-04-06 [0.0.1] GuiDocHighlighter
7
7
  Created: 2023-09-10 [2.2b1] TextBlockData
8
8
 
9
9
  This file is a part of novelWriter
10
- Copyright 2018–2024, Veronica Berglyd Olsen
10
+ Copyright (C) 2019 Veronica Berglyd Olsen and novelWriter contributors
11
11
 
12
12
  This program is free software: you can redistribute it and/or modify
13
13
  it under the terms of the GNU General Public License as published by