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..192b00efc 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -736,9 +736,11 @@ 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._iterFormatBlocks(nwDocAction.BLOCK_COM) + elif action == 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: @@ -1588,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): @@ -1634,159 +1634,189 @@ 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, text: str, toggle: bool = True + ) -> tuple[nwDocAction, str, int]: + """Process the formatting of a single text block.""" # 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:] - cOffset = 2 - if action == nwDocAction.BLOCK_COM: + return nwDocAction.NO_ACTION, "", 0 + elif text.startswith("%~"): + temp = text[2:].lstrip() + offset = len(text) - len(temp) + if toggle and action == nwDocAction.BLOCK_IGN: action = nwDocAction.BLOCK_TXT - elif setText.startswith("%"): - newText = setText[1:] - cOffset = 1 - if action == nwDocAction.BLOCK_COM: + elif text.startswith("%"): + temp = text[1:].lstrip() + offset = len(text) - len(temp) + if toggle and 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: + 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) -> 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 - # Replace the block text + action, text, offset = self._processBlockFormat(action, block.text()) + if action == nwDocAction.NO_ACTION: + return False + + pos = cursor.position() + cursor.beginEditBlock() - posO = cursor.position() - cursor.select(QTextCursor.SelectionType.BlockUnderCursor) - posS = cursor.selectionStart() - cursor.removeSelectedText() - cursor.setPosition(posS) + self._makeSelection(QTextCursor.SelectionType.BlockUnderCursor, cursor) + cursor.insertText(text) + cursor.endEditBlock() - 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() + if (move := pos - offset) >= 0: + cursor.setPosition(move) + self.setTextCursor(cursor) - cursor.insertText(setText) + return True - if posO - cOffset >= 0: - cursor.setPosition(posO - cOffset) + 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. + """ + cursor = self.textCursor() + blocks = self._selectedBlocks(cursor) + if len(blocks) < 2: + 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() - self.setTextCursor(cursor) 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: @@ -2040,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) diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py index 279fce073..0465bed5d 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.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) + ) + # Format > Remove Block Format self.aFmtNoFormat = self.fmtMenu.addAction(self.tr("Remove Block Format")) self.aFmtNoFormat.setShortcuts(["Ctrl+0", "Ctrl+Shift+/"]) 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..93b8bcc8d 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 @@ -956,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) @@ -963,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) @@ -1202,6 +1218,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) @@ -1317,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."""