diff --git a/docs/source/images/fig_build_settings_headings.png b/docs/source/images/fig_build_settings_headings.png index d5219361..a7262725 100644 Binary files a/docs/source/images/fig_build_settings_headings.png and b/docs/source/images/fig_build_settings_headings.png differ diff --git a/docs/source/int_howto.rst b/docs/source/int_howto.rst index 43f28601..fe5b22fa 100644 --- a/docs/source/int_howto.rst +++ b/docs/source/int_howto.rst @@ -75,6 +75,10 @@ Organising Your Text Keep in mind that this is not what the section heading is intended for, so the app will not understand the section heading as a scene, but it will be formatted correctly in the manuscript. + .. versionadded:: 2.4 + You can now distinguish between soft and hard scene breaks with a modified scene header. See + :ref:`a_manuscript_settings_head_hard` for more details. + Other Tools =========== diff --git a/docs/source/project_manuscript.rst b/docs/source/project_manuscript.rst index 91af6245..9383079d 100644 --- a/docs/source/project_manuscript.rst +++ b/docs/source/project_manuscript.rst @@ -63,6 +63,8 @@ In the figure, the green arrow icon and the blue pin icon indicates which docume and the red forbidden icon indicates that a document is explicitly excluded. +.. _a_manuscript_settings_head: + Formatting Headings ------------------- @@ -86,6 +88,7 @@ Any text you add that isn't highlighted in colours will remain in your formatted You can preview the result of these format strings by clicking :guilabel:`Apply`, and then clicking :guilabel:`Preview` in the :guilabel:`Manuscript Build` tool main window. + Scene Separators ^^^^^^^^^^^^^^^^ @@ -99,6 +102,19 @@ the desired separator text as the format. If the format is any piece of static t be treated as a separator. +.. _a_manuscript_settings_head_hard: + +Hard and Soft Scenes +^^^^^^^^^^^^^^^^^^^^ + +If you wish to distinguish between so-called soft and hard scene breaks, where a hard scene break +is understood as a scene break that changes point-of-view character, you can use the modified scene +heading format in your text. You can then give these headings a different formatting in the +:guilabel:`Headings` settings. + +See :ref:`a_fmt_head` for more info on how to format headings in your text. + + Output Settings --------------- diff --git a/docs/source/usage_format.rst b/docs/source/usage_format.rst index 9c4bf73f..f452c5fd 100644 --- a/docs/source/usage_format.rst +++ b/docs/source/usage_format.rst @@ -68,7 +68,7 @@ correctly to produce the intended result. See :ref:`a_struct_heads` for more det Heading level four. For novel documents, the level indicates the start of a new section. Section titles can be replaced by separators or ignored completely when building the manuscript. -For headings level one and two, adding a ``!`` modifies its meaning: +For headings level one through three, adding a ``!`` modifies its meaning: ``#! Title Text`` This tells the build tool that the level one heading is intended to be used for the novel's or @@ -81,6 +81,11 @@ For headings level one and two, adding a ``!`` modifies its meaning: chapter numbers are being used. Such titles are useful for a prologue for instance. See :ref:`a_struct_heads_unnum` for more details. +``###! Title Text`` + This is an alternative scene heading that can be formatted differently in the + :guilabel:`Manuscript Build` tool. It is intended for separating "soft" and "hard" scene breaks. + Otherwise, it behaves identically to a regular scene heading. + .. note:: The space after the ``#`` or ``!`` character is mandatory. The syntax highlighter will change colour and font size when the heading is correctly formatted. diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index 8557a523..d38f3347 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -56,8 +56,10 @@ SETTINGS_TEMPLATE = { "headings.fmtChapter": (str, nwHeadFmt.TITLE), "headings.fmtUnnumbered": (str, nwHeadFmt.TITLE), "headings.fmtScene": (str, "* * *"), + "headings.fmtHardScene": (str, ""), "headings.fmtSection": (str, ""), "headings.hideScene": (bool, False), + "headings.hideHardScene": (bool, False), "headings.hideSection": (bool, True), "headings.centerTitle": (bool, True), "headings.centerChapter": (bool, False), @@ -100,13 +102,15 @@ SETTINGS_LABELS = { "filter.includeInactive": QT_TRANSLATE_NOOP("Builds", "Inactive Documents"), "headings": QT_TRANSLATE_NOOP("Builds", "Headings"), - "headings.fmtTitle": QT_TRANSLATE_NOOP("Builds", "Title Headings"), - "headings.fmtChapter": QT_TRANSLATE_NOOP("Builds", "Chapter Headings"), - "headings.fmtUnnumbered": QT_TRANSLATE_NOOP("Builds", "Unnumbered Headings"), - "headings.fmtScene": QT_TRANSLATE_NOOP("Builds", "Scene Headings"), - "headings.fmtSection": QT_TRANSLATE_NOOP("Builds", "Section Headings"), - "headings.hideScene": QT_TRANSLATE_NOOP("Builds", "Hide Scene Headings"), - "headings.hideSection": QT_TRANSLATE_NOOP("Builds", "Hide Section Headings"), + "headings.fmtTitle": QT_TRANSLATE_NOOP("Builds", "Partition Format"), + "headings.fmtChapter": QT_TRANSLATE_NOOP("Builds", "Chapter Format"), + "headings.fmtUnnumbered": QT_TRANSLATE_NOOP("Builds", "Unnumbered Format"), + "headings.fmtScene": QT_TRANSLATE_NOOP("Builds", "Scene Format"), + "headings.fmtHardScene": QT_TRANSLATE_NOOP("Builds", "Hard Scene Format"), + "headings.fmtSection": QT_TRANSLATE_NOOP("Builds", "Section Format"), + "headings.hideScene": QT_TRANSLATE_NOOP("Builds", "Hide Scene Breaks"), + "headings.hideHardScene": QT_TRANSLATE_NOOP("Builds", "Hide Hard Scene Breaks"), + "headings.hideSection": QT_TRANSLATE_NOOP("Builds", "Hide Section Breaks"), "text.grpContent": QT_TRANSLATE_NOOP("Builds", "Text Content"), "text.includeSynopsis": QT_TRANSLATE_NOOP("Builds", "Include Synopsis"), diff --git a/novelwriter/core/docbuild.py b/novelwriter/core/docbuild.py index f36d7345..23b10791 100644 --- a/novelwriter/core/docbuild.py +++ b/novelwriter/core/docbuild.py @@ -282,6 +282,10 @@ class NWBuildDocument: self._build.getStr("headings.fmtScene"), self._build.getBool("headings.hideScene") ) + bldObj.setHardSceneFormat( + self._build.getStr("headings.fmtHardScene"), + self._build.getBool("headings.hideHardScene") + ) bldObj.setSectionFormat( self._build.getStr("headings.fmtSection"), self._build.getBool("headings.hideSection") diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index 4fd59a19..9b5f25db 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -396,6 +396,8 @@ class NWIndex: return "H1", line[3:].strip() elif line.startswith("##! "): return "H2", line[4:].strip() + elif line.startswith("###! "): + return "H3", line[5:].strip() return "H0", "" def _indexWordCounts(self, tHandle: str, text: str, sTitle: str) -> None: diff --git a/novelwriter/core/tokenizer.py b/novelwriter/core/tokenizer.py index 55d89637..3d52e19a 100644 --- a/novelwriter/core/tokenizer.py +++ b/novelwriter/core/tokenizer.py @@ -156,9 +156,11 @@ class Tokenizer(ABC): self._fmtChapter = nwHeadFmt.TITLE # Formatting for numbered chapters self._fmtUnNum = nwHeadFmt.TITLE # Formatting for unnumbered chapters self._fmtScene = nwHeadFmt.TITLE # Formatting for scenes + self._fmtHScene = nwHeadFmt.TITLE # Formatting for hard scenes self._fmtSection = nwHeadFmt.TITLE # Formatting for sections self._hideScene = False # Do not include scene headings + self._hideHScene = False # Do not include hard scene headings self._hideSection = False # Do not include section headings self._linkHeaders = False # Add an anchor before headings @@ -254,6 +256,12 @@ class Tokenizer(ABC): self._hideScene = hide return + def setHardSceneFormat(self, hFormat: str, hide: bool) -> None: + """Set the hard scene format pattern and hidden status.""" + self._fmtHScene = hFormat.strip() + self._hideHScene = hide + return + def setSectionFormat(self, hFormat: str, hide: bool) -> None: """Set the section format pattern and hidden status.""" self._fmtSection = hFormat.strip() @@ -484,7 +492,7 @@ class Tokenizer(ABC): # Check Line Format # ================= - if aLine[0] == "[": + if aLine.startswith("["): # Special Formats # =============== # Parse special formatting line. This must be a separate if @@ -513,13 +521,13 @@ class Tokenizer(ABC): ] continue - if aLine[0] == "%": + if aLine.startswith("%"): # Comments # ======== # All style comments are processed and the exact type exact # style extracted. Ignored comments on the '%~' format are # skipped completely. - if aLine[1] == "~": + if aLine.startswith("%~"): continue cStyle, cText, _ = processComment(aLine) @@ -542,7 +550,7 @@ class Tokenizer(ABC): if self._doComments and self._keepMarkdown: tmpMarkdown.append(f"{aLine}\n") - elif aLine[0] == "@": + elif aLine.startswith("@"): # Keywords # ======== # Only valid keyword lines are parsed, and any ignored keywords @@ -556,56 +564,70 @@ class Tokenizer(ABC): if self._doKeywords and self._keepMarkdown: tmpMarkdown.append(f"{aLine}\n") - elif aLine[:2] == "# ": - # Partition Headings - # ================== + elif aLine.startswith(("# ", "#! ")): + # Title or Partition Headings + # =========================== + # Main titles are allowed in any document, and they are always + # centred and start on a new page. For novel documents, we also + # reset all counters when such a title is encountered. # Partition headings are only formatted in novel documents, and # otherwise unchanged. Scene separators are disabled # immediately after partitions, and scene numbers are reset. + isPlain = aLine.startswith("# ") nHead += 1 tText = aLine[2:].strip() - tStyle = self.A_NONE + tType = self.T_HEAD1 if isPlain else self.T_TITLE + tStyle = self.A_NONE if isPlain else (self.A_PBB | self.A_CENTRE) if self._isNovel: - tText = self._hFormatter.apply(self._fmtTitle, tText, nHead) - tStyle = self._titleStyle + if isPlain: + tText = self._hFormatter.apply(self._fmtTitle, tText, nHead) + tStyle = self._titleStyle + self._hFormatter.resetScene() + else: + self._hFormatter.resetAll() self._noSep = True - self._hFormatter.resetScene() self._tokens.append(( - self.T_HEAD1, nHead, tText, [], tStyle + tType, nHead, tText, [], tStyle )) if self._keepMarkdown: tmpMarkdown.append(f"{aLine}\n") - elif aLine[:3] == "## ": - # Chapter Headings - # ================ + elif aLine.startswith(("## ", "##! ")): + # (Unnumbered) Chapter Headings + # ============================= # Chapter headings are only formatted in novel documents, and # otherwise unchanged. Chapter numbers are bumped before the # heading is formatted. Scene separators are disabled # immediately after chapter headings, and scene numbers are - # reset. + # reset. Unnumbered chapters are only meaningful in Novel docs, + # so if we're in a note, we keep them as level 2 headings. + isPlain = aLine.startswith("## ") nHead += 1 tText = aLine[3:].strip() + tType = self.T_HEAD2 tStyle = self.A_NONE + tFormat = self._fmtChapter if isPlain else self._fmtUnNum if self._isNovel: - self._hFormatter.incChapter() - tText = self._hFormatter.apply(self._fmtChapter, tText, nHead) + if isPlain: + self._hFormatter.incChapter() + tText = self._hFormatter.apply(tFormat, tText, nHead) + tType = self.T_HEAD2 if isPlain else self.T_UNNUM tStyle = self._chapterStyle self._noSep = True self._hFormatter.resetScene() self._tokens.append(( - self.T_HEAD2, nHead, tText, [], tStyle + tType, nHead, tText, [], tStyle )) if self._keepMarkdown: tmpMarkdown.append(f"{aLine}\n") - elif aLine[:4] == "### ": - # Scene Headings - # ============== + elif aLine.startswith(("### ", "###! ")): + # (Hard) Scene Headings + # ===================== # Scene headings in novel documents are treated as centred # separators if the formatting does not change the text. If the # format is empty, the scene can be hidden or a blank paragraph @@ -614,19 +636,22 @@ class Tokenizer(ABC): # separators immediately after other titles. Scene numbers are # always incremented before formatting. For notes, the heading # is unchanged. + isPlain = aLine.startswith("### ") nHead += 1 tText = aLine[4:].strip() tType = self.T_HEAD3 tStyle = self.A_NONE + sHide = self._hideScene if isPlain else self._hideHScene + tFormat = self._fmtScene if isPlain else self._fmtHScene if self._isNovel: self._hFormatter.incScene() - tText = self._hFormatter.apply(self._fmtScene, tText, nHead) + tText = self._hFormatter.apply(tFormat, tText, nHead) tStyle = self._sceneStyle if tText == "": - tType = self.T_EMPTY if self._noSep or self._hideScene else self.T_SKIP + tType = self.T_EMPTY if self._noSep or sHide else self.T_SKIP tStyle = self.A_NONE - elif tText == self._fmtScene: + elif tText == tFormat: tText = "" if self._noSep else tText tType = self.T_EMPTY if self._noSep else self.T_SEP tStyle = self.A_NONE if self._noSep else self.A_CENTRE @@ -638,7 +663,7 @@ class Tokenizer(ABC): if self._keepMarkdown: tmpMarkdown.append(f"{aLine}\n") - elif aLine[:5] == "#### ": + elif aLine.startswith("#### "): # Section Headings # ================= # Section headings in novel docs are treated as centred @@ -664,46 +689,6 @@ class Tokenizer(ABC): if self._keepMarkdown: tmpMarkdown.append(f"{aLine}\n") - elif aLine[:3] == "#! ": - # Main Title - # ========== - # Main titles are allowed in any document, and they are always - # centred and start on a new page. For novel documents, we also - # reset all counters when such a title is encountered. - - nHead += 1 - self._tokens.append(( - self.T_TITLE, nHead, aLine[3:].strip(), [], self.A_PBB | self.A_CENTRE - )) - if self._isNovel: - self._noSep = True - self._hFormatter.resetAll() - if self._keepMarkdown: - tmpMarkdown.append(f"{aLine}\n") - - elif aLine[:4] == "##! ": - # Unnumbered Chapter Headings - # =========================== - # Unnumbered chapters are only meaningful in Novel docs, so if - # we're in a note, we convert them to a plain level 2 heading. - - nHead += 1 - tText = aLine[4:].strip() - tType = self.T_HEAD2 - tStyle = self.A_NONE - if self._isNovel: - tText = self._hFormatter.apply(self._fmtUnNum, tText, nHead) - tType = self.T_UNNUM - tStyle = self._chapterStyle - self._noSep = True - self._hFormatter.resetScene() - - self._tokens.append(( - tType, nHead, tText, [], tStyle - )) - if self._keepMarkdown: - tmpMarkdown.append(f"{aLine}\n") - else: # Text Lines # ========== diff --git a/novelwriter/enum.py b/novelwriter/enum.py index 85724c95..b7e38e40 100644 --- a/novelwriter/enum.py +++ b/novelwriter/enum.py @@ -112,21 +112,22 @@ class nwDocAction(Enum): BLOCK_TXT = 19 BLOCK_TTL = 20 BLOCK_UNN = 21 - REPL_SNG = 22 - REPL_DBL = 23 - RM_BREAKS = 24 - ALIGN_L = 25 - ALIGN_C = 26 - ALIGN_R = 27 - INDENT_L = 28 - INDENT_R = 29 - SC_ITALIC = 30 - SC_BOLD = 31 - SC_STRIKE = 32 - SC_ULINE = 33 - SC_MARK = 34 - SC_SUP = 35 - SC_SUB = 36 + BLOCK_HSC = 22 + REPL_SNG = 23 + REPL_DBL = 24 + RM_BREAKS = 25 + ALIGN_L = 26 + ALIGN_C = 27 + ALIGN_R = 28 + INDENT_L = 29 + INDENT_R = 30 + SC_ITALIC = 31 + SC_BOLD = 32 + SC_STRIKE = 33 + SC_ULINE = 34 + SC_MARK = 35 + SC_SUP = 36 + SC_SUB = 37 # END Enum nwDocAction diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index ed0ba5fe..0d3bf0cc 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -744,6 +744,8 @@ class GuiDocEditor(QPlainTextEdit): self._formatBlock(nwDocAction.BLOCK_TTL) elif action == nwDocAction.BLOCK_UNN: self._formatBlock(nwDocAction.BLOCK_UNN) + elif action == nwDocAction.BLOCK_HSC: + self._formatBlock(nwDocAction.BLOCK_HSC) elif action == nwDocAction.REPL_SNG: self._replaceQuotes("'", self._typSQuoteO, self._typSQuoteC) elif action == nwDocAction.REPL_DBL: @@ -1651,6 +1653,9 @@ class GuiDocEditor(QPlainTextEdit): elif text.startswith("##! "): temp = text[4:] offset = 4 + elif text.startswith("###! "): + temp = text[5:] + offset = 5 elif text.startswith(">> "): temp = text[3:] offset = 3 @@ -1702,6 +1707,9 @@ class GuiDocEditor(QPlainTextEdit): elif action == nwDocAction.BLOCK_UNN: text = f"##! {temp}" offset -= 4 + elif action == nwDocAction.BLOCK_HSC: + text = f"###! {temp}" + offset -= 5 elif action == nwDocAction.ALIGN_L: text = f"{temp} <<" elif action == nwDocAction.ALIGN_C: diff --git a/novelwriter/gui/dochighlight.py b/novelwriter/gui/dochighlight.py index 5ead13a1..6dd2322a 100644 --- a/novelwriter/gui/dochighlight.py +++ b/novelwriter/gui/dochighlight.py @@ -300,7 +300,7 @@ class GuiDocHighlighter(QSyntaxHighlighter): # so we force a return here return - elif text.startswith(("# ", "#! ", "## ", "##! ", "### ", "#### ")): + elif text.startswith(("# ", "#! ", "## ", "##! ", "### ", "###! ", "#### ")): self.setCurrentBlockState(self.BLOCK_TITLE) if text.startswith("# "): # Heading 1 @@ -327,6 +327,10 @@ class GuiDocHighlighter(QSyntaxHighlighter): self.setFormat(0, 3, self._hStyles["head2h"]) self.setFormat(3, len(text), self._hStyles["header2"]) + elif text.startswith("###! "): # Hard Scene + self.setFormat(0, 4, self._hStyles["head3h"]) + self.setFormat(4, len(text), self._hStyles["header3"]) + elif text.startswith("%"): # Comments self.setCurrentBlockState(self.BLOCK_TEXT) cStyle, _, cPos = processComment(text) diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py index 3e4c3850..86cbd3d8 100644 --- a/novelwriter/gui/mainmenu.py +++ b/novelwriter/gui/mainmenu.py @@ -735,6 +735,12 @@ class GuiMainMenu(QMenuBar): lambda: self.requestDocAction.emit(nwDocAction.BLOCK_UNN) ) + # Format > Hard Scene + self.aFmtHardSc = self.fmtMenu.addAction(self.tr("Hard Scene")) + self.aFmtHardSc.triggered.connect( + lambda: self.requestDocAction.emit(nwDocAction.BLOCK_HSC) + ) + # Format > Separator self.fmtMenu.addSeparator() diff --git a/novelwriter/text/counting.py b/novelwriter/text/counting.py index c91838b3..697cf5e8 100644 --- a/novelwriter/text/counting.py +++ b/novelwriter/text/counting.py @@ -106,6 +106,9 @@ def standardCounter(text: str) -> tuple[int, int, int]: elif line[:4] == "##! ": line = line[4:] countPara = False + elif line[:5] == "###! ": + line = line[5:] + countPara = False wCount += len(line.split()) cCount += len(line) diff --git a/novelwriter/tools/manuscript.py b/novelwriter/tools/manuscript.py index 45df91a7..3f52e9c8 100644 --- a/novelwriter/tools/manuscript.py +++ b/novelwriter/tools/manuscript.py @@ -610,16 +610,15 @@ class _DetailsWidget(QWidget): item.setText(0, build.getLabel("headings")) item.setText(1, "") self.listView.addTopLevelItem(item) - entries = [ + for key in [ "headings.fmtTitle", "headings.fmtChapter", "headings.fmtUnnumbered", - "headings.fmtScene", "headings.fmtSection" - ] - for key in entries: + "headings.fmtScene", "headings.fmtHardScene", "headings.fmtSection" + ]: sub = QTreeWidgetItem() sub.setText(0, build.getLabel(key)) sub.setText(1, hFmt.apply(build.getStr(key), title, 0)) item.addChild(sub) - for key in ["headings.hideScene", "headings.hideSection"]: + for key in ["headings.hideScene", "headings.hideHardScene", "headings.hideSection"]: sub = QTreeWidgetItem() sub.setText(0, build.getLabel(key)) sub.setIcon(1, on if build.getBool(key) else off) @@ -630,11 +629,10 @@ class _DetailsWidget(QWidget): item.setText(0, build.getLabel("text.grpContent")) item.setText(1, "") self.listView.addTopLevelItem(item) - entries = [ + for key in [ "text.includeSynopsis", "text.includeComments", "text.includeKeywords", "text.includeBodyText", - ] - for key in entries: + ]: sub = QTreeWidgetItem() sub.setText(0, build.getLabel(key)) sub.setIcon(1, on if build.getBool(key) else off) diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index 14a3d529..b298adbd 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -584,7 +584,8 @@ class _HeadingsTab(NScrollablePage): EDIT_CHAPTER = 2 EDIT_UNNUM = 3 EDIT_SCENE = 4 - EDIT_SECTION = 5 + EDIT_HSCENE = 5 + EDIT_SECTION = 6 def __init__(self, buildMain: GuiBuildSettings, build: BuildSettings) -> None: super().__init__(parent=buildMain) @@ -603,7 +604,7 @@ class _HeadingsTab(NScrollablePage): self.formatBox.setHorizontalSpacing(vSp) # Title Heading - self.lblTitle = QLabel(self._build.getLabel("headings.fmtTitle")) + self.lblTitle = QLabel(self._build.getLabel("headings.fmtTitle"), self) self.fmtTitle = QLineEdit("", self) self.fmtTitle.setReadOnly(True) self.btnTitle = QToolButton(self) @@ -619,7 +620,7 @@ class _HeadingsTab(NScrollablePage): self.formatBox.addLayout(wrapTitle, 0, 1) # Chapter Heading - self.lblChapter = QLabel(self._build.getLabel("headings.fmtChapter")) + self.lblChapter = QLabel(self._build.getLabel("headings.fmtChapter"), self) self.fmtChapter = QLineEdit("", self) self.fmtChapter.setReadOnly(True) self.btnChapter = QToolButton(self) @@ -635,7 +636,7 @@ class _HeadingsTab(NScrollablePage): self.formatBox.addLayout(wrapChapter, 1, 1) # Unnumbered Chapter Heading - self.lblUnnumbered = QLabel(self._build.getLabel("headings.fmtUnnumbered")) + self.lblUnnumbered = QLabel(self._build.getLabel("headings.fmtUnnumbered"), self) self.fmtUnnumbered = QLineEdit("", self) self.fmtUnnumbered.setReadOnly(True) self.btnUnnumbered = QToolButton(self) @@ -652,7 +653,7 @@ class _HeadingsTab(NScrollablePage): # Scene Heading sceneHideTip = self._build.getLabel("headings.hideScene") - self.lblScene = QLabel(self._build.getLabel("headings.fmtScene")) + self.lblScene = QLabel(self._build.getLabel("headings.fmtScene"), self) self.fmtScene = QLineEdit("", self) self.fmtScene.setReadOnly(True) self.btnScene = QToolButton(self) @@ -677,9 +678,36 @@ class _HeadingsTab(NScrollablePage): self.formatBox.addLayout(wrapScene, 3, 1) self.formatBox.addLayout(wrapSceneHide, 3, 2) + # Alt Scene Heading + hardSceneHideTip = self._build.getLabel("headings.hideHardScene") + self.lblHScene = QLabel(self._build.getLabel("headings.fmtHardScene"), self) + self.fmtHScene = QLineEdit("", self) + self.fmtHScene.setReadOnly(True) + self.btnHScene = QToolButton(self) + self.btnHScene.setIcon(SHARED.theme.getIcon("edit")) + self.btnHScene.clicked.connect(lambda: self._editHeading(self.EDIT_HSCENE)) + self.hdeHScene = QLabel(self.tr("Hide")) + self.hdeHScene.setToolTip(hardSceneHideTip) + self.swtHScene = NSwitch(self, height=iPx) + self.swtHScene.setToolTip(hardSceneHideTip) + + wrapHScene = QHBoxLayout() + wrapHScene.addWidget(self.fmtHScene) + wrapHScene.addWidget(self.btnHScene) + wrapHScene.setSpacing(bSp) + + wrapHSceneHide = QHBoxLayout() + wrapHSceneHide.addWidget(self.hdeHScene) + wrapHSceneHide.addWidget(self.swtHScene) + wrapHSceneHide.setSpacing(bSp) + + self.formatBox.addWidget(self.lblHScene, 4, 0) + self.formatBox.addLayout(wrapHScene, 4, 1) + self.formatBox.addLayout(wrapHSceneHide, 4, 2) + # Section Heading sectionHideTip = self._build.getLabel("headings.hideSection") - self.lblSection = QLabel(self._build.getLabel("headings.fmtSection")) + self.lblSection = QLabel(self._build.getLabel("headings.fmtSection"), self) self.fmtSection = QLineEdit("", self) self.fmtSection.setReadOnly(True) self.btnSection = QToolButton(self) @@ -700,9 +728,9 @@ class _HeadingsTab(NScrollablePage): wrapSectionHide.addWidget(self.swtSection) wrapSectionHide.setSpacing(bSp) - self.formatBox.addWidget(self.lblSection, 4, 0) - self.formatBox.addLayout(wrapSection, 4, 1) - self.formatBox.addLayout(wrapSectionHide, 4, 2) + self.formatBox.addWidget(self.lblSection, 5, 0) + self.formatBox.addLayout(wrapSection, 5, 1) + self.formatBox.addLayout(wrapSectionHide, 5, 2) # Edit Form # ========= @@ -830,8 +858,10 @@ class _HeadingsTab(NScrollablePage): self.fmtChapter.setText(self._build.getStr("headings.fmtChapter")) self.fmtUnnumbered.setText(self._build.getStr("headings.fmtUnnumbered")) self.fmtScene.setText(self._build.getStr("headings.fmtScene")) + self.fmtHScene.setText(self._build.getStr("headings.fmtHardScene")) self.fmtSection.setText(self._build.getStr("headings.fmtSection")) self.swtScene.setChecked(self._build.getBool("headings.hideScene")) + self.swtHScene.setChecked(self._build.getBool("headings.hideHardScene")) self.swtSection.setChecked(self._build.getBool("headings.hideSection")) self.centerTitle.setChecked(self._build.getBool("headings.centerTitle")) self.centerChapter.setChecked(self._build.getBool("headings.centerChapter")) @@ -844,6 +874,7 @@ class _HeadingsTab(NScrollablePage): def saveContent(self) -> None: """Save choices back into build object.""" self._build.setValue("headings.hideScene", self.swtScene.isChecked()) + self._build.setValue("headings.hideHardScene", self.swtHScene.isChecked()) self._build.setValue("headings.hideSection", self.swtSection.isChecked()) self._build.setValue("headings.centerTitle", self.centerTitle.isChecked()) self._build.setValue("headings.centerChapter", self.centerChapter.isChecked()) @@ -881,6 +912,9 @@ class _HeadingsTab(NScrollablePage): elif heading == self.EDIT_SCENE: text = self.fmtScene.text() label = self._build.getLabel("headings.fmtScene") + elif heading == self.EDIT_HSCENE: + text = self.fmtHScene.text() + label = self._build.getLabel("headings.fmtHardScene") elif heading == self.EDIT_SECTION: text = self.fmtSection.text() label = self._build.getLabel("headings.fmtSection") @@ -916,6 +950,9 @@ class _HeadingsTab(NScrollablePage): elif heading == self.EDIT_SCENE: self.fmtScene.setText(text) self._build.setValue("headings.fmtScene", text) + elif heading == self.EDIT_HSCENE: + self.fmtHScene.setText(text) + self._build.setValue("headings.fmtHardScene", text) elif heading == self.EDIT_SECTION: self.fmtSection.setText(text) self._build.setValue("headings.fmtSection", text) diff --git a/sample/content/bc0cbd2a407f3.nwd b/sample/content/bc0cbd2a407f3.nwd index 00642bba..2b51fe91 100644 --- a/sample/content/bc0cbd2a407f3.nwd +++ b/sample/content/bc0cbd2a407f3.nwd @@ -1,8 +1,8 @@ %%~name: Another Scene %%~path: 6a2d6d5f4f401/bc0cbd2a407f3 %%~kind: NOVEL/DOCUMENT -%%~hash: b3f9e4b097f7041c77489876577014c435a8be0c -%%~date: Unknown/2023-08-25 16:51:57 +%%~hash: d35e2629876e627b1a2418affa40d477022984f8 +%%~date: Unknown/2024-03-11 22:56:28 ### Another Scene @pov: John @@ -13,7 +13,7 @@ Adding more scenes to a chapter is as easy as adding more scene files with a lev In fact, if you wish, you can add all the scenes in the chapter file too. All novelWriter cares about is the level of the headings and the order in which they appear. -### More Scenes +###! More Scenes @pov: Jane @focus: John diff --git a/tests/test_core/test_core_buildsettings.py b/tests/test_core/test_core_buildsettings.py index 78f09600..bccccae1 100644 --- a/tests/test_core/test_core_buildsettings.py +++ b/tests/test_core/test_core_buildsettings.py @@ -196,8 +196,8 @@ def testCoreBuildSettings_BuildValues(): assert build.getFloat(floatSetting) == 2.5 # Check labels - assert build.getLabel(strSetting) == "Title Headings" - assert BuildSettings.getLabel(strSetting) == "Title Headings" + assert build.getLabel(strSetting) == "Partition Format" + assert BuildSettings.getLabel(strSetting) == "Partition Format" # Pack the values data = build.pack() diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py index 2555b411..3ae976d4 100644 --- a/tests/test_core/test_core_index.py +++ b/tests/test_core/test_core_index.py @@ -502,6 +502,19 @@ def testCoreIndex_ScanText(mockGUI, fncPath, mockRnd): assert index._itemIndex[tHandle]["T0001"].paraCount == 1 # type: ignore assert index._itemIndex[tHandle]["T0001"].synopsis == "" # type: ignore + assert index.scanText(tHandle, ( + "###! Hard Scene\n\n" + "We're no longer following Jane, but John!\n\n" + )) + assert index._itemIndex[cHandle]["T0001"].references == {} # type: ignore + assert index._itemIndex[tHandle]["T0001"].level == "H3" # type: ignore + assert index._itemIndex[tHandle]["T0001"].line == 1 # type: ignore + assert index._itemIndex[tHandle]["T0001"].title == "Hard Scene" # type: ignore + assert index._itemIndex[tHandle]["T0001"].charCount == 51 # type: ignore + assert index._itemIndex[tHandle]["T0001"].wordCount == 9 # type: ignore + assert index._itemIndex[tHandle]["T0001"].paraCount == 1 # type: ignore + assert index._itemIndex[tHandle]["T0001"].synopsis == "" # type: ignore + # Page wo/Title # ============= diff --git a/tests/test_core/test_core_tokenizer.py b/tests/test_core/test_core_tokenizer.py index d2e05708..03343d0c 100644 --- a/tests/test_core/test_core_tokenizer.py +++ b/tests/test_core/test_core_tokenizer.py @@ -47,6 +47,7 @@ def testCoreToken_Setters(mockGUI): assert tokens._fmtChapter == nwHeadFmt.TITLE assert tokens._fmtUnNum == nwHeadFmt.TITLE assert tokens._fmtScene == nwHeadFmt.TITLE + assert tokens._fmtHScene == nwHeadFmt.TITLE assert tokens._fmtSection == nwHeadFmt.TITLE assert tokens._textFont == "Serif" assert tokens._textSize == 11 @@ -74,6 +75,7 @@ def testCoreToken_Setters(mockGUI): tokens.setChapterFormat(f"C: {nwHeadFmt.TITLE}") tokens.setUnNumberedFormat(f"U: {nwHeadFmt.TITLE}") tokens.setSceneFormat(f"S: {nwHeadFmt.TITLE}", True) + tokens.setHardSceneFormat(f"H: {nwHeadFmt.TITLE}", True) tokens.setSectionFormat(f"X: {nwHeadFmt.TITLE}", True) tokens.setFont("Monospace", 10, True) tokens.setLineHeight(2.0) @@ -97,6 +99,7 @@ def testCoreToken_Setters(mockGUI): assert tokens._fmtChapter == f"C: {nwHeadFmt.TITLE}" assert tokens._fmtUnNum == f"U: {nwHeadFmt.TITLE}" assert tokens._fmtScene == f"S: {nwHeadFmt.TITLE}" + assert tokens._fmtHScene == f"H: {nwHeadFmt.TITLE}" assert tokens._fmtSection == f"X: {nwHeadFmt.TITLE}" assert tokens._textFont == "Monospace" assert tokens._textSize == 10 @@ -1741,7 +1744,7 @@ def testCoreToken_HeaderCounterAndVisibility(mockGUI): "# Title Two\n\n" "### Scene Three\n\n" "Text\n\n" - "### Scene Four\n\n" + "###! Scene Four\n\n" "Text\n\n" ) md.setTitleFormat(f"T: {nwHeadFmt.TITLE}") @@ -1749,13 +1752,14 @@ def testCoreToken_HeaderCounterAndVisibility(mockGUI): md.setSectionFormat("", True) # Static Separator - md.setSceneFormat("* * *", False) + md.setSceneFormat("~", False) + md.setHardSceneFormat("* * *", False) md.tokenizeText() md.doConvert() assert md.result == ( "# T: Title One\n\n" "Text\n\n" - "* * *\n\n" + "~\n\n" "Text\n\n" "# T: Title Two\n\n" "Text\n\n" @@ -1765,6 +1769,7 @@ def testCoreToken_HeaderCounterAndVisibility(mockGUI): # Scene Title Formatted md.setSceneFormat(f"S: {nwHeadFmt.TITLE}", False) + md.setHardSceneFormat(f"H: {nwHeadFmt.TITLE}", False) md.tokenizeText() md.doConvert() assert md.result == ( @@ -1776,7 +1781,7 @@ def testCoreToken_HeaderCounterAndVisibility(mockGUI): "# T: Title Two\n\n" "### S: Scene Three\n\n" "Text\n\n" - "### S: Scene Four\n\n" + "### H: Scene Four\n\n" "Text\n\n" ) @@ -1793,7 +1798,7 @@ def testCoreToken_HeaderCounterAndVisibility(mockGUI): "## Chapter Two\n\n" "### Scene Three\n\n" "Text\n\n" - "### Scene Four\n\n" + "###! Scene Four\n\n" "Text\n\n" ) md.setTitleFormat(f"T: {nwHeadFmt.TITLE}") @@ -1802,6 +1807,7 @@ def testCoreToken_HeaderCounterAndVisibility(mockGUI): # Static Separator md.setSceneFormat("* * *", False) + md.setHardSceneFormat("* * *", False) md.tokenizeText() md.doConvert() assert md.result == ( @@ -1818,6 +1824,7 @@ def testCoreToken_HeaderCounterAndVisibility(mockGUI): # Scene Title Formatted md.setSceneFormat(f"S: {nwHeadFmt.TITLE}", False) + md.setHardSceneFormat(f"H: {nwHeadFmt.TITLE}", False) md.tokenizeText() md.doConvert() assert md.result == ( @@ -1830,15 +1837,18 @@ def testCoreToken_HeaderCounterAndVisibility(mockGUI): "## C: Chapter Two\n\n" "### S: Scene Three\n\n" "Text\n\n" - "### S: Scene Four\n\n" + "### H: Scene Four\n\n" "Text\n\n" ) # Counter Handling, Novel Titles # ============================== + # This also checks that only numbered chapters bump the counter md._text = ( "#! Novel One\n\n" + "##! Prologue\n\n" + "Text\n\n" "## Chapter One\n\n" "### Scene One\n\n" "Text\n\n" @@ -1847,9 +1857,11 @@ def testCoreToken_HeaderCounterAndVisibility(mockGUI): "## Chapter Two\n\n" "### Scene Three\n\n" "Text\n\n" - "### Scene Four\n\n" + "###! Scene Four\n\n" "Text\n\n" "#! Novel Two\n\n" + "##! Prologue\n\n" + "Text\n\n" "## Chapter One\n\n" "### Scene One\n\n" "Text\n\n" @@ -1858,13 +1870,17 @@ def testCoreToken_HeaderCounterAndVisibility(mockGUI): "## Chapter Two\n\n" "### Scene Three\n\n" "Text\n\n" - "### Scene Four\n\n" + "###! Scene Four\n\n" "Text\n\n" ) md.setTitleFormat(f"T: {nwHeadFmt.TITLE}") md.setChapterFormat(f"C {nwHeadFmt.CH_NUM}: {nwHeadFmt.TITLE}") - md.setSceneFormat(f"S {nwHeadFmt.CH_NUM}.{nwHeadFmt.SC_NUM} ({nwHeadFmt.SC_ABS}): " - f"{nwHeadFmt.TITLE}", False) + md.setSceneFormat( + f"S {nwHeadFmt.CH_NUM}.{nwHeadFmt.SC_NUM} ({nwHeadFmt.SC_ABS}): {nwHeadFmt.TITLE}", False + ) + md.setHardSceneFormat( + f"H {nwHeadFmt.CH_NUM}.{nwHeadFmt.SC_NUM} ({nwHeadFmt.SC_ABS}): {nwHeadFmt.TITLE}", False + ) md.setSectionFormat("", True) # Two Novel Format @@ -1872,6 +1888,8 @@ def testCoreToken_HeaderCounterAndVisibility(mockGUI): md.doConvert() assert md.result == ( "# Novel One\n\n" + "## Prologue\n\n" + "Text\n\n" "## C 1: Chapter One\n\n" "### S 1.1 (1): Scene One\n\n" "Text\n\n" @@ -1880,9 +1898,11 @@ def testCoreToken_HeaderCounterAndVisibility(mockGUI): "## C 2: Chapter Two\n\n" "### S 2.1 (3): Scene Three\n\n" "Text\n\n" - "### S 2.2 (4): Scene Four\n\n" + "### H 2.2 (4): Scene Four\n\n" "Text\n\n" "# Novel Two\n\n" + "## Prologue\n\n" + "Text\n\n" "## C 1: Chapter One\n\n" "### S 1.1 (1): Scene One\n\n" "Text\n\n" @@ -1891,7 +1911,7 @@ def testCoreToken_HeaderCounterAndVisibility(mockGUI): "## C 2: Chapter Two\n\n" "### S 2.1 (3): Scene Three\n\n" "Text\n\n" - "### S 2.2 (4): Scene Four\n\n" + "### H 2.2 (4): Scene Four\n\n" "Text\n\n" ) diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index c8b1d581..d7475952 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -1134,13 +1134,20 @@ def testGuiEditor_BlockFormatting(qtbot, monkeypatch, nwGUI, projPath, ipsumText assert nwGUI.docEditor.getText() == "Title\n\n" assert nwGUI.docEditor.getCursorPosition() == 2 - # Strip Unnumbered CHapter + # Strip Unnumbered Chapter nwGUI.docEditor.replaceText("##! Title\n\n") nwGUI.docEditor.setCursorPosition(5) assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True assert nwGUI.docEditor.getText() == "Title\n\n" assert nwGUI.docEditor.getCursorPosition() == 1 + # Strip Hard Scene + nwGUI.docEditor.replaceText("###! Title\n\n") + nwGUI.docEditor.setCursorPosition(6) + assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True + assert nwGUI.docEditor.getText() == "Title\n\n" + assert nwGUI.docEditor.getCursorPosition() == 1 + # Strip Text nwGUI.docEditor.replaceText("Generic text\n\n") nwGUI.docEditor.setCursorPosition(5) @@ -1302,6 +1309,13 @@ def testGuiEditor_BlockFormatting(qtbot, monkeypatch, nwGUI, projPath, ipsumText assert nwGUI.docEditor.getText() == "##! Some text\n\n" assert nwGUI.docEditor.getCursorPosition() == 9 + # Hard Scene + nwGUI.docEditor.replaceText("Some text\n\n") + nwGUI.docEditor.setCursorPosition(5) + assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_HSC) is True + assert nwGUI.docEditor.getText() == "###! Some text\n\n" + assert nwGUI.docEditor.getCursorPosition() == 10 + # Left Indent nwGUI.docEditor.replaceText("Some text\n\n") nwGUI.docEditor.setCursorPosition(5) diff --git a/tests/test_gui/test_gui_mainmenu.py b/tests/test_gui/test_gui_mainmenu.py index 5d11b0d2..b0a0351f 100644 --- a/tests/test_gui/test_gui_mainmenu.py +++ b/tests/test_gui/test_gui_mainmenu.py @@ -126,6 +126,11 @@ def testGuiMenu_EditFormat(qtbot, monkeypatch, nwGUI, prjLipsum): fmtStr = "##! Pellentesque nec erat ut nulla posuere commodo." assert nwGUI.docEditor.getText()[54:105] == fmtStr + # Hard Scene + nwGUI.mainMenu.aFmtHardSc.activate(QAction.Trigger) + fmtStr = "###! Pellentesque nec erat ut nulla posuere commodo." + assert nwGUI.docEditor.getText()[54:106] == fmtStr + # Clear Format nwGUI.mainMenu.aFmtNoFormat.activate(QAction.Trigger) assert nwGUI.docEditor.getText()[54:101] == cleanText diff --git a/tests/test_tools/test_tools_manussettings.py b/tests/test_tools/test_tools_manussettings.py index 45f97358..75d7d83f 100644 --- a/tests/test_tools/test_tools_manussettings.py +++ b/tests/test_tools/test_tools_manussettings.py @@ -327,12 +327,14 @@ def testBuildSettings_Headings(qtbot: QtBot, nwGUI: GuiMain): chTitle = f"Chapter: {nwHeadFmt.TITLE}" unTitle = f"Interlude: {nwHeadFmt.TITLE}" scTitle = f"Scene: {nwHeadFmt.TITLE}" + shTitle = f"Hard Scene: {nwHeadFmt.TITLE}" sxTitle = f"Section: {nwHeadFmt.TITLE}" build.setValue("headings.fmtTitle", ttTitle) build.setValue("headings.fmtChapter", chTitle) build.setValue("headings.fmtUnnumbered", unTitle) build.setValue("headings.fmtScene", scTitle) + build.setValue("headings.fmtHardScene", shTitle) build.setValue("headings.fmtSection", sxTitle) build.setValue("headings.hideScene", False) build.setValue("headings.hideSection", False) @@ -351,6 +353,7 @@ def testBuildSettings_Headings(qtbot: QtBot, nwGUI: GuiMain): assert headTab.fmtChapter.text() == chTitle assert headTab.fmtUnnumbered.text() == unTitle assert headTab.fmtScene.text() == scTitle + assert headTab.fmtHScene.text() == shTitle assert headTab.fmtSection.text() == sxTitle assert headTab.swtScene.isChecked() is False assert headTab.swtSection.isChecked() is False @@ -396,6 +399,14 @@ def testBuildSettings_Headings(qtbot: QtBot, nwGUI: GuiMain): headTab.btnApply.click() assert headTab.editTextBox.isEnabled() is False + # Hard Scene + headTab.btnHScene.click() + assert headTab._editing == headTab.EDIT_HSCENE + assert headTab.editTextBox.isEnabled() is True + assert headTab.editTextBox.toPlainText() == shTitle + headTab.btnApply.click() + assert headTab.editTextBox.isEnabled() is False + # Section headTab.btnSection.click() assert headTab._editing == headTab.EDIT_SECTION @@ -455,6 +466,11 @@ def testBuildSettings_Headings(qtbot: QtBot, nwGUI: GuiMain): headTab.btnApply.click() assert build.getStr("headings.fmtScene") == nwHeadFmt.TITLE + headTab.btnHScene.click() + headTab.editTextBox.setPlainText(nwHeadFmt.TITLE) + headTab.btnApply.click() + assert build.getStr("headings.fmtHardScene") == nwHeadFmt.TITLE + headTab.btnSection.click() headTab.editTextBox.setPlainText(nwHeadFmt.TITLE) headTab.btnApply.click()