diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 2ad62e27..689efbd1 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -736,9 +736,9 @@ class GuiDocEditor(QPlainTextEdit): 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 @@ class GuiDocEditor(QPlainTextEdit): 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 @@ class GuiDocEditor(QPlainTextEdit): return True - def _formatBlock(self, action: nwDocAction) -> bool: + 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 + if text.startswith("@"): + logger.error("Cannot apply block format to keyword/value line") + 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 text.startswith("%"): + temp = text[1:].lstrip() + offset = len(text) - len(temp) + if action == nwDocAction.BLOCK_COM: + action = nwDocAction.BLOCK_TXT + 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: + temp = text + offset = 0 + + # Also remove formatting tags at the end + 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: + text = f"% {temp}" + offset -= 2 + elif action == nwDocAction.BLOCK_IGN: + text = f"%~ {temp}" + offset -= 3 + elif action == nwDocAction.BLOCK_H1: + text = f"# {temp}" + offset -= 2 + elif action == nwDocAction.BLOCK_H2: + text = f"## {temp}" + offset -= 3 + elif action == nwDocAction.BLOCK_H3: + text = f"### {temp}" + offset -= 4 + elif action == nwDocAction.BLOCK_H4: + text = f"#### {temp}" + offset -= 5 + elif action == nwDocAction.BLOCK_TTL: + text = f"#! {temp}" + offset -= 3 + elif action == nwDocAction.BLOCK_UNN: + text = f"##! {temp}" + offset -= 4 + elif action == nwDocAction.ALIGN_L: + text = f"{temp} <<" + elif action == nwDocAction.ALIGN_C: + text = f">> {temp} <<" + offset -= 3 + elif action == nwDocAction.ALIGN_R: + text = f">> {temp}" + offset -= 3 + elif action == nwDocAction.INDENT_L: + text = f"> {temp}" + offset -= 2 + elif action == nwDocAction.INDENT_R: + text = f"{temp} <" + elif action == nwDocAction.BLOCK_TXT: + 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() - block = cursor.block() + 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 - setText = block.text() - hasText = len(setText) > 0 - if setText.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) - if action == nwDocAction.BLOCK_IGN: - action = nwDocAction.BLOCK_TXT - elif setText.startswith("%"): - newText = setText[1:].lstrip() - cOffset = len(setText) - len(newText) - 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 - else: - newText = setText - cOffset = 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] - - # Apply new format - 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 - elif action == nwDocAction.BLOCK_H2: - setText = "## "+newText - cOffset -= 3 - elif action == nwDocAction.BLOCK_H3: - setText = "### "+newText - cOffset -= 4 - elif action == nwDocAction.BLOCK_H4: - setText = "#### "+newText - cOffset -= 5 - elif action == nwDocAction.BLOCK_TTL: - setText = "#! "+newText - cOffset -= 3 - elif action == nwDocAction.BLOCK_UNN: - setText = "##! "+newText - cOffset -= 4 - elif action == nwDocAction.ALIGN_L: - setText = newText+" <<" - elif action == nwDocAction.ALIGN_C: - setText = ">> "+newText+" <<" - cOffset -= 3 - elif action == nwDocAction.ALIGN_R: - setText = ">> "+newText - cOffset -= 3 - elif action == nwDocAction.INDENT_L: - setText = "> "+newText - cOffset -= 2 - elif action == nwDocAction.INDENT_R: - setText = newText+" <" - elif action == nwDocAction.BLOCK_TXT: - setText = newText - else: - logger.error("Unknown or unsupported block format requested: '%s'", str(action)) + 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 @@ class GuiDocEditor(QPlainTextEdit): # 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 6c2070fe..7a96693f 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)