From 9c1bdef9adfc866a7c72e6d7d3799bcdfa4ccd6c Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 10 Feb 2024 15:40:30 +0100 Subject: [PATCH 1/6] Add block format that completely ignores a line of text in the tokenizer --- docs/source/usage_shortcuts.rst | 1 + novelwriter/core/tokenizer.py | 4 ++++ novelwriter/enum.py | 35 +++++++++++++++++---------------- novelwriter/gui/doceditor.py | 17 ++++++++++------ novelwriter/gui/mainmenu.py | 7 +++++++ 5 files changed, 41 insertions(+), 23 deletions(-) diff --git a/docs/source/usage_shortcuts.rst b/docs/source/usage_shortcuts.rst index 8c9fb8cb4..3dc15049c 100644 --- a/docs/source/usage_shortcuts.rst +++ b/docs/source/usage_shortcuts.rst @@ -107,6 +107,7 @@ Text Formatting Shortcuts ":kbd:`Ctrl+D`", "Strike through selected text, or word under cursor" ":kbd:`Ctrl+I`", "Format selected text, or word under cursor, with emphasis (italic)" ":kbd:`Ctrl+Shift+/`", "Remove block formatting for block under cursor" + ":kbd:`Ctrl+Shift+D`", "Toggle block format as ignored text" Other Editor Shortcuts diff --git a/novelwriter/core/tokenizer.py b/novelwriter/core/tokenizer.py index 7f0bb7d26..90f771f81 100644 --- a/novelwriter/core/tokenizer.py +++ b/novelwriter/core/tokenizer.py @@ -467,6 +467,10 @@ def tokenizeText(self) -> None: continue if aLine[0] == "%": + if aLine[1] == "~": + # Completely ignore the paragraph + continue + cStyle, cText, _ = processComment(aLine) if cStyle == nwComment.SYNOPSIS: self._tokens.append(( diff --git a/novelwriter/enum.py b/novelwriter/enum.py index bbfed7753..aa9106e32 100644 --- a/novelwriter/enum.py +++ b/novelwriter/enum.py @@ -108,23 +108,24 @@ class nwDocAction(Enum): BLOCK_H3 = 15 BLOCK_H4 = 16 BLOCK_COM = 17 - BLOCK_TXT = 18 - BLOCK_TTL = 19 - BLOCK_UNN = 20 - REPL_SNG = 21 - REPL_DBL = 22 - RM_BREAKS = 23 - ALIGN_L = 24 - ALIGN_C = 25 - ALIGN_R = 26 - INDENT_L = 27 - INDENT_R = 28 - SC_ITALIC = 29 - SC_BOLD = 30 - SC_STRIKE = 31 - SC_ULINE = 32 - SC_SUP = 33 - SC_SUB = 34 + BLOCK_IGN = 18 + 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_SUP = 34 + SC_SUB = 35 # END Enum nwDocAction diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index b3223aab5..2ad62e27a 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -737,6 +737,8 @@ def docAction(self, action: nwDocAction) -> bool: self._formatBlock(nwDocAction.BLOCK_H4) elif action == nwDocAction.BLOCK_COM: self._formatBlock(nwDocAction.BLOCK_COM) + elif action == nwDocAction.BLOCK_IGN: + self._formatBlock(nwDocAction.BLOCK_IGN) elif action == nwDocAction.BLOCK_TXT: self._formatBlock(nwDocAction.BLOCK_TXT) elif action == nwDocAction.BLOCK_TTL: @@ -1648,14 +1650,14 @@ def _formatBlock(self, action: nwDocAction) -> bool: if setText.startswith("@"): logger.error("Cannot apply block format to keyword/value line") return False - elif setText.startswith("% "): - newText = setText[2:] - cOffset = 2 - if action == nwDocAction.BLOCK_COM: + elif setText.startswith("%~"): + newText = setText[2:].lstrip() + cOffset = len(setText) - len(newText) + if action == nwDocAction.BLOCK_IGN: action = nwDocAction.BLOCK_TXT elif setText.startswith("%"): - newText = setText[1:] - cOffset = 1 + newText = setText[1:].lstrip() + cOffset = len(setText) - len(newText) if action == nwDocAction.BLOCK_COM: action = nwDocAction.BLOCK_TXT elif setText.startswith("# "): @@ -1706,6 +1708,9 @@ def _formatBlock(self, action: nwDocAction) -> bool: if action == nwDocAction.BLOCK_COM: setText = "% "+newText cOffset -= 2 + elif action == nwDocAction.BLOCK_IGN: + setText = "%~ "+newText + cOffset -= 3 elif action == nwDocAction.BLOCK_H1: setText = "# "+newText cOffset -= 2 diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py index 279fce073..6f91bd05e 100644 --- a/novelwriter/gui/mainmenu.py +++ b/novelwriter/gui/mainmenu.py @@ -781,6 +781,13 @@ def _buildFormatMenu(self) -> None: lambda: self.requestDocAction.emit(nwDocAction.BLOCK_COM) ) + # Format > Ignore Text + self.aFmtComment = self.fmtMenu.addAction(self.tr("Toggle Ignore Text")) + self.aFmtComment.setShortcut("Ctrl+Shift+D") + self.aFmtComment.triggered.connect( + lambda: self.requestDocAction.emit(nwDocAction.BLOCK_IGN) + ) + # Format > Remove Block Format self.aFmtNoFormat = self.fmtMenu.addAction(self.tr("Remove Block Format")) self.aFmtNoFormat.setShortcuts(["Ctrl+0", "Ctrl+Shift+/"]) From c3e6b4e10a62c0f35db229617cbb7dbfb16844e0 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 10 Feb 2024 15:49:49 +0100 Subject: [PATCH 2/6] Update tests --- novelwriter/gui/mainmenu.py | 6 +++--- tests/test_core/test_core_tokenizer.py | 8 ++++++++ tests/test_gui/test_gui_doceditor.py | 19 +++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py index 6f91bd05e..0465bed5d 100644 --- a/novelwriter/gui/mainmenu.py +++ b/novelwriter/gui/mainmenu.py @@ -782,9 +782,9 @@ def _buildFormatMenu(self) -> None: ) # Format > Ignore Text - self.aFmtComment = self.fmtMenu.addAction(self.tr("Toggle Ignore Text")) - self.aFmtComment.setShortcut("Ctrl+Shift+D") - self.aFmtComment.triggered.connect( + self.aFmtIgnore = self.fmtMenu.addAction(self.tr("Toggle Ignore Text")) + self.aFmtIgnore.setShortcut("Ctrl+Shift+D") + self.aFmtIgnore.triggered.connect( lambda: self.requestDocAction.emit(nwDocAction.BLOCK_IGN) ) diff --git a/tests/test_core/test_core_tokenizer.py b/tests/test_core/test_core_tokenizer.py index 56099589a..163cd1512 100644 --- a/tests/test_core/test_core_tokenizer.py +++ b/tests/test_core/test_core_tokenizer.py @@ -452,6 +452,14 @@ def testCoreToken_MetaFormat(mockGUI): tokens.tokenizeText() assert tokens.allMarkdown[-1] == "% A comment\n\n" + # Ignore Text + tokens._text = "%~ Some text\n" + tokens.tokenizeText() + assert tokens._tokens == [ + (Tokenizer.T_EMPTY, 0, "", [], Tokenizer.A_NONE), + ] + assert tokens.allMarkdown[-1] == "\n" + # Synopsis tokens._text = "%synopsis: The synopsis\n" tokens.tokenizeText() diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index 15b20efbb..6c2070fec 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -579,6 +579,11 @@ def testGuiEditor_Actions(qtbot, nwGUI, projPath, ipsumText, mockRnd): assert nwGUI.docEditor.docAction(nwDocAction.BLOCK_COM) is True assert nwGUI.docEditor.getText() == "#### Scene Title\n\n% Scene text.\n\n" + # Ignore Text + nwGUI.docEditor.setCursorPosition(20) + assert nwGUI.docEditor.docAction(nwDocAction.BLOCK_IGN) is True + assert nwGUI.docEditor.getText() == "#### Scene Title\n\n%~ Scene text.\n\n" + # Text nwGUI.docEditor.setCursorPosition(20) assert nwGUI.docEditor.docAction(nwDocAction.BLOCK_TXT) is True @@ -1202,6 +1207,20 @@ def testGuiEditor_BlockFormatting(qtbot, monkeypatch, nwGUI, projPath, ipsumText assert nwGUI.docEditor.getText() == "Some text\n\n" assert nwGUI.docEditor.getCursorPosition() == 4 + # Toggle Ignore Text w/Space + nwGUI.docEditor.replaceText("%~ Some text\n\n") + nwGUI.docEditor.setCursorPosition(5) + assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_IGN) is True + assert nwGUI.docEditor.getText() == "Some text\n\n" + assert nwGUI.docEditor.getCursorPosition() == 2 + + # Toggle Ignore Text wo/Space + nwGUI.docEditor.replaceText("%~Some text\n\n") + nwGUI.docEditor.setCursorPosition(5) + assert nwGUI.docEditor._formatBlock(nwDocAction.BLOCK_IGN) is True + assert nwGUI.docEditor.getText() == "Some text\n\n" + assert nwGUI.docEditor.getCursorPosition() == 3 + # Header 1 nwGUI.docEditor.replaceText("Some text\n\n") nwGUI.docEditor.setCursorPosition(5) From 8a4174817373f4f5ad04a4cb85e19b496c4cbb52 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 10 Feb 2024 16:43:12 +0100 Subject: [PATCH 3/6] Refactor block formatting to support multi-block selections --- novelwriter/gui/doceditor.py | 224 +++++++++++++++------------ tests/test_gui/test_gui_doceditor.py | 13 +- 2 files changed, 135 insertions(+), 102 deletions(-) diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 2ad62e27a..689efbd12 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -736,9 +736,9 @@ def docAction(self, action: nwDocAction) -> bool: elif action == nwDocAction.BLOCK_H4: self._formatBlock(nwDocAction.BLOCK_H4) elif action == nwDocAction.BLOCK_COM: - self._formatBlock(nwDocAction.BLOCK_COM) + self._formatAllBlocks(nwDocAction.BLOCK_COM) elif action == nwDocAction.BLOCK_IGN: - self._formatBlock(nwDocAction.BLOCK_IGN) + self._formatAllBlocks(nwDocAction.BLOCK_IGN) elif action == nwDocAction.BLOCK_TXT: self._formatBlock(nwDocAction.BLOCK_TXT) elif action == nwDocAction.BLOCK_TTL: @@ -1590,9 +1590,7 @@ def _replaceQuotes(self, sQuote: str, oQuote: str, cQuote: str) -> bool: posS = cursor.selectionStart() posE = cursor.selectionEnd() - closeCheck = ( - " ", "\n", nwUnicode.U_LSEP, nwUnicode.U_PSEP - ) + closeCheck = (" ", "\n", nwUnicode.U_LSEP, nwUnicode.U_PSEP) self._allowAutoReplace(False) for posC in range(posS, posE+1): @@ -1636,116 +1634,129 @@ def _replaceQuotes(self, sQuote: str, oQuote: str, cQuote: str) -> bool: return True - def _formatBlock(self, action: nwDocAction) -> bool: - """Change the block format of the block under the cursor.""" - cursor = self.textCursor() - block = cursor.block() - if not block.isValid(): - logger.debug("Invalid block selected for action '%s'", str(action)) - return False + def _processBlockFormat( + self, action: nwDocAction, block: QTextBlock + ) -> tuple[nwDocAction, str, int]: + """Process the formatting of a single text block.""" + text = block.text() # Remove existing format first, if any - setText = block.text() - hasText = len(setText) > 0 - if setText.startswith("@"): + if text.startswith("@"): logger.error("Cannot apply block format to keyword/value line") - return False - elif setText.startswith("%~"): - newText = setText[2:].lstrip() - cOffset = len(setText) - len(newText) + return nwDocAction.NO_ACTION, "", 0 + elif text.startswith("%~"): + temp = text[2:].lstrip() + offset = len(text) - len(temp) if action == nwDocAction.BLOCK_IGN: action = nwDocAction.BLOCK_TXT - elif setText.startswith("%"): - newText = setText[1:].lstrip() - cOffset = len(setText) - len(newText) + elif text.startswith("%"): + temp = text[1:].lstrip() + offset = len(text) - len(temp) if action == nwDocAction.BLOCK_COM: action = nwDocAction.BLOCK_TXT - elif setText.startswith("# "): - newText = setText[2:] - cOffset = 2 - elif setText.startswith("## "): - newText = setText[3:] - cOffset = 3 - elif setText.startswith("### "): - newText = setText[4:] - cOffset = 4 - elif setText.startswith("#### "): - newText = setText[5:] - cOffset = 5 - elif setText.startswith("#! "): - newText = setText[3:] - cOffset = 3 - elif setText.startswith("##! "): - newText = setText[4:] - cOffset = 4 - elif setText.startswith(">> "): - newText = setText[3:] - cOffset = 3 - elif setText.startswith("> ") and action != nwDocAction.INDENT_R: - newText = setText[2:] - cOffset = 2 - elif setText.startswith(">>"): - newText = setText[2:] - cOffset = 2 - elif setText.startswith(">") and action != nwDocAction.INDENT_R: - newText = setText[1:] - cOffset = 1 + elif text.startswith("# "): + temp = text[2:] + offset = 2 + elif text.startswith("## "): + temp = text[3:] + offset = 3 + elif text.startswith("### "): + temp = text[4:] + offset = 4 + elif text.startswith("#### "): + temp = text[5:] + offset = 5 + elif text.startswith("#! "): + temp = text[3:] + offset = 3 + elif text.startswith("##! "): + temp = text[4:] + offset = 4 + elif text.startswith(">> "): + temp = text[3:] + offset = 3 + elif text.startswith("> ") and action != nwDocAction.INDENT_R: + temp = text[2:] + offset = 2 + elif text.startswith(">>"): + temp = text[2:] + offset = 2 + elif text.startswith(">") and action != nwDocAction.INDENT_R: + temp = text[1:] + offset = 1 else: - newText = setText - cOffset = 0 + temp = text + offset = 0 # Also remove formatting tags at the end - if setText.endswith(" <<"): - newText = newText[:-3] - elif setText.endswith(" <") and action != nwDocAction.INDENT_L: - newText = newText[:-2] - elif setText.endswith("<<"): - newText = newText[:-2] - elif setText.endswith("<") and action != nwDocAction.INDENT_L: - newText = newText[:-1] + if text.endswith(" <<"): + temp = temp[:-3] + elif text.endswith(" <") and action != nwDocAction.INDENT_L: + temp = temp[:-2] + elif text.endswith("<<"): + temp = temp[:-2] + elif text.endswith("<") and action != nwDocAction.INDENT_L: + temp = temp[:-1] # Apply new format if action == nwDocAction.BLOCK_COM: - setText = "% "+newText - cOffset -= 2 + text = f"% {temp}" + offset -= 2 elif action == nwDocAction.BLOCK_IGN: - setText = "%~ "+newText - cOffset -= 3 + text = f"%~ {temp}" + offset -= 3 elif action == nwDocAction.BLOCK_H1: - setText = "# "+newText - cOffset -= 2 + text = f"# {temp}" + offset -= 2 elif action == nwDocAction.BLOCK_H2: - setText = "## "+newText - cOffset -= 3 + text = f"## {temp}" + offset -= 3 elif action == nwDocAction.BLOCK_H3: - setText = "### "+newText - cOffset -= 4 + text = f"### {temp}" + offset -= 4 elif action == nwDocAction.BLOCK_H4: - setText = "#### "+newText - cOffset -= 5 + text = f"#### {temp}" + offset -= 5 elif action == nwDocAction.BLOCK_TTL: - setText = "#! "+newText - cOffset -= 3 + text = f"#! {temp}" + offset -= 3 elif action == nwDocAction.BLOCK_UNN: - setText = "##! "+newText - cOffset -= 4 + text = f"##! {temp}" + offset -= 4 elif action == nwDocAction.ALIGN_L: - setText = newText+" <<" + text = f"{temp} <<" elif action == nwDocAction.ALIGN_C: - setText = ">> "+newText+" <<" - cOffset -= 3 + text = f">> {temp} <<" + offset -= 3 elif action == nwDocAction.ALIGN_R: - setText = ">> "+newText - cOffset -= 3 + text = f">> {temp}" + offset -= 3 elif action == nwDocAction.INDENT_L: - setText = "> "+newText - cOffset -= 2 + text = f"> {temp}" + offset -= 2 elif action == nwDocAction.INDENT_R: - setText = newText+" <" + text = f"{temp} <" elif action == nwDocAction.BLOCK_TXT: - setText = newText + text = temp else: logger.error("Unknown or unsupported block format requested: '%s'", str(action)) + return nwDocAction.NO_ACTION, "", 0 + + return action, text, offset + + def _formatBlock(self, action: nwDocAction, block: QTextBlock | None = None) -> bool: + """Change the block format of the block under the cursor.""" + cursor = self.textCursor() + if block is None: + block = cursor.block() + if not block.isValid(): + logger.debug("Invalid block selected for action '%s'", str(action)) + return False + + # Remove existing format first, if any + hasText = block.length() > 0 + action, text, offset = self._processBlockFormat(action, block) + if action == nwDocAction.NO_ACTION: return False # Replace the block text @@ -1761,37 +1772,48 @@ def _formatBlock(self, action: nwDocAction) -> bool: # first before we can add back the text to it. cursor.insertBlock() - cursor.insertText(setText) + cursor.insertText(text) - if posO - cOffset >= 0: - cursor.setPosition(posO - cOffset) + if posO - offset >= 0: + cursor.setPosition(posO - offset) cursor.endEditBlock() self.setTextCursor(cursor) return True + def _formatAllBlocks(self, action: nwDocAction) -> bool: + """Iterate over all selected blocks and apply format. If no + selection is made, just forward the call to the single block + formatter function. + """ + self._formatBlock(action) + return True + + def _selectedBlocks(self, cursor: QTextCursor) -> list[QTextBlock]: + """Return a list of all blocks selected by a cursor.""" + if cursor.hasSelection(): + iS = self._qDocument.findBlock(cursor.selectionStart()).blockNumber() + iE = self._qDocument.findBlock(cursor.selectionEnd()).blockNumber() + return [self._qDocument.findBlockByNumber(i) for i in range(iS, iE+1)] + return [] + def _removeInParLineBreaks(self) -> None: """Strip line breaks within paragraphs in the selected text.""" cursor = self.textCursor() + if not cursor.hasSelection(): + cursor.select(QTextCursor.SelectionType.Document) - iS = 0 - iE = self._qDocument.blockCount() - 1 rS = 0 rE = self._qDocument.characterCount() - if cursor.hasSelection(): - sBlock = self._qDocument.findBlock(cursor.selectionStart()) - eBlock = self._qDocument.findBlock(cursor.selectionEnd()) - iS = sBlock.blockNumber() - iE = eBlock.blockNumber() - rS = sBlock.position() - rE = eBlock.position() + eBlock.length() + if sBlocks := self._selectedBlocks(cursor): + rS = sBlocks[0].position() + rE = sBlocks[-1].position() + sBlocks[-1].length() # Clean up the text currPar = [] cleanText = "" - for i in range(iS, iE+1): - cBlock = self._qDocument.findBlockByNumber(i) + for cBlock in sBlocks: cText = cBlock.text() if cText.strip() == "": if currPar: diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index 6c2070fec..7a96693f5 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -961,6 +961,17 @@ def testGuiEditor_TextManipulation(qtbot, nwGUI, projPath, ipsumText, mockRnd): parOne = ipsumText[0].replace(" ", "\n", 5) parTwo = ipsumText[1].replace(" ", "\n", 5) + # Check Blocks + cursor = nwGUI.docEditor.textCursor() + cursor.clearSelection() + text = "### A Scene\n\n%s\n\n%s" % (parOne, parTwo) + nwGUI.docEditor.replaceText(text) + nwGUI.docEditor.setCursorPosition(45) + assert len(nwGUI.docEditor._selectedBlocks(cursor)) == 0 + + cursor.select(QTextCursor.SelectionType.Document) + assert len(nwGUI.docEditor._selectedBlocks(cursor)) == 15 + # Remove All text = "### A Scene\n\n%s\n\n%s" % (parOne, parTwo) nwGUI.docEditor.replaceText(text) @@ -968,7 +979,7 @@ def testGuiEditor_TextManipulation(qtbot, nwGUI, projPath, ipsumText, mockRnd): nwGUI.docEditor._removeInParLineBreaks() assert nwGUI.docEditor.getText() == "### A Scene\n\n%s\n" % "\n\n".join(ipsumText[0:2]) - # Remove First Paragraph + # Remove in First Paragraph # Second paragraphs should remain unchanged text = "### A Scene\n\n%s\n\n%s" % (parOne, parTwo) nwGUI.docEditor.replaceText(text) From bc2f91e54f69e31dbd01edc4d4504dccd8fa78d5 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 10 Feb 2024 18:20:51 +0100 Subject: [PATCH 4/6] Allow comment, ignore and clear formatting to be applied to all selected text --- novelwriter/gui/doceditor.py | 73 +++++++++++++++++++----------------- 1 file changed, 39 insertions(+), 34 deletions(-) diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 689efbd12..8465515ea 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -736,11 +736,11 @@ def docAction(self, action: nwDocAction) -> bool: elif action == nwDocAction.BLOCK_H4: self._formatBlock(nwDocAction.BLOCK_H4) elif action == nwDocAction.BLOCK_COM: - self._formatAllBlocks(nwDocAction.BLOCK_COM) + self._iterFormatBlocks(nwDocAction.BLOCK_COM) elif action == nwDocAction.BLOCK_IGN: - self._formatAllBlocks(nwDocAction.BLOCK_IGN) + self._iterFormatBlocks(nwDocAction.BLOCK_IGN) elif action == nwDocAction.BLOCK_TXT: - self._formatBlock(nwDocAction.BLOCK_TXT) + self._iterFormatBlocks(nwDocAction.BLOCK_TXT) elif action == nwDocAction.BLOCK_TTL: self._formatBlock(nwDocAction.BLOCK_TTL) elif action == nwDocAction.BLOCK_UNN: @@ -1635,11 +1635,9 @@ def _replaceQuotes(self, sQuote: str, oQuote: str, cQuote: str) -> bool: return True def _processBlockFormat( - self, action: nwDocAction, block: QTextBlock + self, action: nwDocAction, text: str, toggle: bool = True ) -> tuple[nwDocAction, str, int]: """Process the formatting of a single text block.""" - text = block.text() - # Remove existing format first, if any if text.startswith("@"): logger.error("Cannot apply block format to keyword/value line") @@ -1647,12 +1645,12 @@ def _processBlockFormat( elif text.startswith("%~"): temp = text[2:].lstrip() offset = len(text) - len(temp) - if action == nwDocAction.BLOCK_IGN: + if toggle and action == nwDocAction.BLOCK_IGN: action = nwDocAction.BLOCK_TXT elif text.startswith("%"): temp = text[1:].lstrip() offset = len(text) - len(temp) - if action == nwDocAction.BLOCK_COM: + if toggle and action == nwDocAction.BLOCK_COM: action = nwDocAction.BLOCK_TXT elif text.startswith("# "): temp = text[2:] @@ -1744,50 +1742,55 @@ def _processBlockFormat( return action, text, offset - def _formatBlock(self, action: nwDocAction, block: QTextBlock | None = None) -> bool: + def _formatBlock(self, action: nwDocAction) -> bool: """Change the block format of the block under the cursor.""" cursor = self.textCursor() - if block is None: - block = cursor.block() + block = cursor.block() if not block.isValid(): logger.debug("Invalid block selected for action '%s'", str(action)) return False - # Remove existing format first, if any - hasText = block.length() > 0 - action, text, offset = self._processBlockFormat(action, block) + action, text, offset = self._processBlockFormat(action, block.text()) if action == nwDocAction.NO_ACTION: return False - # Replace the block text - cursor.beginEditBlock() - posO = cursor.position() - cursor.select(QTextCursor.SelectionType.BlockUnderCursor) - posS = cursor.selectionStart() - cursor.removeSelectedText() - cursor.setPosition(posS) - - if posS > 0 and hasText: - # If the block already had text, we must insert a new block - # first before we can add back the text to it. - cursor.insertBlock() + pos = cursor.position() + cursor.beginEditBlock() + self._makeSelection(QTextCursor.SelectionType.BlockUnderCursor, cursor) cursor.insertText(text) - - if posO - offset >= 0: - cursor.setPosition(posO - offset) - cursor.endEditBlock() + + if (move := pos - offset) >= 0: + cursor.setPosition(move) self.setTextCursor(cursor) return True - def _formatAllBlocks(self, action: nwDocAction) -> bool: + def _iterFormatBlocks(self, action: nwDocAction) -> bool: """Iterate over all selected blocks and apply format. If no selection is made, just forward the call to the single block formatter function. """ - self._formatBlock(action) + cursor = self.textCursor() + blocks = self._selectedBlocks(cursor) + if len(blocks) < 1: + return self._formatBlock(action) + + toggle = True + cursor.beginEditBlock() + for block in blocks: + blockText = block.text() + pAction, text, _ = self._processBlockFormat(action, blockText, toggle) + if pAction != nwDocAction.NO_ACTION and blockText.strip(): + action = pAction # First block decides further actions + cursor.setPosition(block.position()) + self._makeSelection(QTextCursor.SelectionType.BlockUnderCursor, cursor) + cursor.insertText(text) + toggle = False + + cursor.endEditBlock() + return True def _selectedBlocks(self, cursor: QTextCursor) -> list[QTextBlock]: @@ -2067,9 +2070,11 @@ def _autoSelect(self) -> QTextCursor: return cursor - def _makeSelection(self, mode: QTextCursor.SelectionType) -> None: + def _makeSelection(self, mode: QTextCursor.SelectionType, + cursor: QTextCursor | None = None) -> None: """Select text based on selection mode.""" - cursor = self.textCursor() + if cursor is None: + cursor = self.textCursor() cursor.clearSelection() cursor.select(mode) From 1c3b252b6bdcc5c20606456c7f191935d72c283e Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 10 Feb 2024 18:22:21 +0100 Subject: [PATCH 5/6] Add test coverage of multi-block formatting --- tests/test_gui/test_gui_doceditor.py | 74 ++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index 7a96693f5..93b8bcc8d 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -1347,6 +1347,80 @@ def testGuiEditor_BlockFormatting(qtbot, monkeypatch, nwGUI, projPath, ipsumText # END Test testGuiEditor_BlockFormatting +@pytest.mark.gui +def testGuiEditor_MultiBlockFormatting(qtbot, nwGUI, projPath, ipsumText, mockRnd): + """Test the block formatting function.""" + buildTestProject(nwGUI, projPath) + assert nwGUI.openDocument(C.hSceneDoc) is True + + text = "### A Scene\n\n@char: Jane, John\n\n" + "\n\n".join(ipsumText) + "\n\n" + nwGUI.docEditor.replaceText(text) + assert [x[:5] for x in nwGUI.docEditor.getText().splitlines()] == [ + "### A", "", "@char", "", "Lorem", "", "Nulla", "", "Nulla", "", "Pelle", "", "Integ", "" + ] + + # Toggle Comment + cursor = nwGUI.docEditor.textCursor() + cursor.setPosition(50) + cursor.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.KeepAnchor, 2000) + nwGUI.docEditor.setTextCursor(cursor) + + nwGUI.docEditor._iterFormatBlocks(nwDocAction.BLOCK_COM) + assert [x[:5] for x in nwGUI.docEditor.getText().splitlines()] == [ + "### A", "", "@char", "", "% Lor", "", "% Nul", "", "% Nul", "", "% Pel", "", "Integ", "" + ] + + # Un-toggle the second + cursor = nwGUI.docEditor.textCursor() + cursor.setPosition(800) + nwGUI.docEditor.setTextCursor(cursor) + + nwGUI.docEditor._iterFormatBlocks(nwDocAction.BLOCK_COM) + assert [x[:5] for x in nwGUI.docEditor.getText().splitlines()] == [ + "### A", "", "@char", "", "% Lor", "", "Nulla", "", "% Nul", "", "% Pel", "", "Integ", "" + ] + + # Un-toggle all + cursor = nwGUI.docEditor.textCursor() + cursor.setPosition(50) + cursor.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.KeepAnchor, 3000) + nwGUI.docEditor.setTextCursor(cursor) + + nwGUI.docEditor._iterFormatBlocks(nwDocAction.BLOCK_COM) + assert [x[:5] for x in nwGUI.docEditor.getText().splitlines()] == [ + "### A", "", "@char", "", "Lorem", "", "Nulla", "", "Nulla", "", "Pelle", "", "Integ", "" + ] + + # Toggle Ignore Text + cursor = nwGUI.docEditor.textCursor() + cursor.setPosition(50) + cursor.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.KeepAnchor, 2000) + nwGUI.docEditor.setTextCursor(cursor) + + nwGUI.docEditor._iterFormatBlocks(nwDocAction.BLOCK_IGN) + assert [x[:5] for x in nwGUI.docEditor.getText().splitlines()] == [ + "### A", "", "@char", "", "%~ Lo", "", "%~ Nu", "", "%~ Nu", "", "%~ Pe", "", "Integ", "" + ] + + # Clear all paragraphs + cursor = nwGUI.docEditor.textCursor() + cursor.setPosition(50) + cursor.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.KeepAnchor, 3000) + nwGUI.docEditor.setTextCursor(cursor) + + nwGUI.docEditor._iterFormatBlocks(nwDocAction.BLOCK_TXT) + assert [x[:5] for x in nwGUI.docEditor.getText().splitlines()] == [ + "### A", "", "@char", "", "Lorem", "", "Nulla", "", "Nulla", "", "Pelle", "", "Integ", "" + ] + + # Final text should be identical to initial text + assert nwGUI.docEditor.getText() == text + + # qtbot.stop() + +# END Test testGuiEditor_MultiBlockFormatting + + @pytest.mark.gui def testGuiEditor_Tags(qtbot, nwGUI, projPath, ipsumText, mockRnd): """Test the document editor tags functionality.""" From 2f202666026dbdfed7b2c194ce989fcd3b439954 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 10 Feb 2024 18:26:18 +0100 Subject: [PATCH 6/6] Use regular block format function for single block --- novelwriter/gui/doceditor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 8465515ea..192b00efc 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -1774,7 +1774,7 @@ def _iterFormatBlocks(self, action: nwDocAction) -> bool: """ cursor = self.textCursor() blocks = self._selectedBlocks(cursor) - if len(blocks) < 1: + if len(blocks) < 2: return self._formatBlock(action) toggle = True