diff --git a/CHANGELOG.md b/CHANGELOG.md index b486e8c..ce68192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 0.2.1.6 + + * Some code cleanup and more coverage + * Improve code block styling / escaping out of the block in various scenarios + # 0.2.1.5 * Go back to font size 10 (I might add a switcher later) diff --git a/bouquin/markdown_editor.py b/bouquin/markdown_editor.py index c3b631b..a38ca1f 100644 --- a/bouquin/markdown_editor.py +++ b/bouquin/markdown_editor.py @@ -127,6 +127,16 @@ class MarkdownEditor(QTextEdit): finally: self._updating = False + def _is_inside_code_block(self, block): + """Return True if 'block' is inside a fenced code block (based on fences above).""" + inside = False + b = block.previous() + while b.isValid(): + if b.text().strip().startswith("```"): + inside = not inside + b = b.previous() + return inside + def _update_code_block_row_backgrounds(self): """Paint a full-width background for each line that is in a fenced code block.""" doc = self.document() @@ -354,7 +364,7 @@ class MarkdownEditor(QTextEdit): edit.beginEditBlock() edit.setPosition(start) edit.setPosition(start + 2, QTextCursor.KeepAnchor) - edit.insertText("```\n\n```") + edit.insertText("```\n\n```\n") edit.endEditBlock() # place caret on the blank line between the fences @@ -362,6 +372,52 @@ class MarkdownEditor(QTextEdit): c.setPosition(new_pos) self.setTextCursor(c) return + # Step out of a code block with Down at EOF + if event.key() == Qt.Key.Key_Down: + c = self.textCursor() + b = c.block() + pos_in_block = c.position() - b.position() + line = b.text() + + def next_is_closing(bb): + nb = bb.next() + return nb.isValid() and nb.text().strip().startswith("```") + + # Case A: caret is on the line BEFORE the closing fence, at EOL → jump after the fence + if ( + self._is_inside_code_block(b) + and pos_in_block >= len(line) + and next_is_closing(b) + ): + fence_block = b.next() + after_fence = fence_block.next() + if not after_fence.isValid(): + # make a line after the fence + edit = QTextCursor(self.document()) + endpos = fence_block.position() + len(fence_block.text()) + edit.setPosition(endpos) + edit.insertText("\n") + after_fence = fence_block.next() + c.setPosition(after_fence.position()) + self.setTextCursor(c) + if hasattr(self, "_update_code_block_row_backgrounds"): + self._update_code_block_row_backgrounds() + return + + # Case B: caret is ON the closing fence, and it's EOF → create a line and move to it + if ( + b.text().strip().startswith("```") + and self._is_inside_code_block(b) + and not b.next().isValid() + ): + edit = QTextCursor(self.document()) + edit.setPosition(b.position() + len(b.text())) + edit.insertText("\n") + c.setPosition(b.position() + len(b.text()) + 1) + self.setTextCursor(c) + if hasattr(self, "_update_code_block_row_backgrounds"): + self._update_code_block_row_backgrounds() + return # Handle Enter key for smart list continuation AND code blocks if event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter: @@ -580,25 +636,80 @@ class MarkdownEditor(QTextEdit): self.setFocus() def apply_code(self): - """Insert or toggle code block.""" - cursor = self.textCursor() + """Insert a fenced code block, or navigate fences without creating inline backticks.""" + c = self.textCursor() + doc = self.document() - if cursor.hasSelection(): - # Wrap selection in code fence - selected = cursor.selectedText() - # selectedText() uses Unicode paragraph separator, replace with newline - selected = selected.replace("\u2029", "\n") - new_text = f"```\n{selected}\n```" - cursor.insertText(new_text) - else: - # Insert code block template - cursor.insertText("```\n\n```") - cursor.movePosition( - QTextCursor.MoveOperation.Up, QTextCursor.MoveMode.MoveAnchor, 1 - ) - self.setTextCursor(cursor) + if c.hasSelection(): + # Wrap selection and ensure exactly one newline after the closing fence + selected = c.selectedText().replace("\u2029", "\n") + c.insertText(f"```\n{selected.rstrip()}\n```\n") + if hasattr(self, "_update_code_block_row_backgrounds"): + self._update_code_block_row_backgrounds() + self.setFocus() + return - # Return focus to editor + block = c.block() + line = block.text() + pos_in_block = c.position() - block.position() + stripped = line.strip() + + # If we're on a fence line, be helpful but never insert inline fences + if stripped.startswith("```"): + # Is this fence opening or closing? (look at blocks above) + inside_before = self._is_inside_code_block(block.previous()) + if inside_before: + # This fence closes the block → ensure a line after, then move there + endpos = block.position() + len(line) + edit = QTextCursor(doc) + edit.setPosition(endpos) + if not block.next().isValid(): + edit.insertText("\n") + c.setPosition(endpos + 1) + self.setTextCursor(c) + if hasattr(self, "_update_code_block_row_backgrounds"): + self._update_code_block_row_backgrounds() + self.setFocus() + return + else: + # Opening fence → move caret to the next line (inside the block) + nb = block.next() + if not nb.isValid(): + e = QTextCursor(doc) + e.setPosition(block.position() + len(line)) + e.insertText("\n") + nb = block.next() + c.setPosition(nb.position()) + self.setTextCursor(c) + self.setFocus() + return + + # If we're inside a block (but not on a fence), don't mutate text + if self._is_inside_code_block(block): + self.setFocus() + return + + # Outside any block → create a clean template on its own lines (never inline) + start_pos = c.position() + before = line[:pos_in_block] + + edit = QTextCursor(doc) + edit.beginEditBlock() + + # If there is text before the caret on the line, start the block on a new line + lead_break = "\n" if before else "" + # Insert the block; trailing newline guarantees you can Down-arrow out later + insert = f"{lead_break}```\n\n```\n" + edit.setPosition(start_pos) + edit.insertText(insert) + edit.endEditBlock() + + # Put caret on the blank line inside the block + c.setPosition(start_pos + len(lead_break) + 4) # after "```\n" + self.setTextCursor(c) + + if hasattr(self, "_update_code_block_row_backgrounds"): + self._update_code_block_row_backgrounds() self.setFocus() def apply_heading(self, size: int): diff --git a/pyproject.toml b/pyproject.toml index fdf55ca..81a5cc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bouquin" -version = "0.2.1.5" +version = "0.2.1.6" description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." authors = ["Miguel Jacq "] readme = "README.md" diff --git a/tests/test_markdown_editor.py b/tests/test_markdown_editor.py index 82fde94..002ab63 100644 --- a/tests/test_markdown_editor.py +++ b/tests/test_markdown_editor.py @@ -6,12 +6,29 @@ from bouquin.markdown_editor import MarkdownEditor from bouquin.theme import ThemeManager, ThemeConfig, Theme +def text(editor) -> str: + return editor.toPlainText() + + +def lines_keep(editor): + """Split preserving a trailing empty line if the text ends with '\\n'.""" + return text(editor).split("\n") + + +def press_backtick(qtbot, widget, n=1): + """Send physical backtick key events (avoid IME/dead-key issues).""" + for _ in range(n): + qtbot.keyClick(widget, Qt.Key_QuoteLeft) + + @pytest.fixture def editor(app, qtbot): themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) ed = MarkdownEditor(themes) qtbot.addWidget(ed) ed.show() + qtbot.waitExposed(ed) + ed.setFocus() return ed @@ -56,24 +73,6 @@ def test_insert_image_from_path(editor, tmp_path): assert "data:image/image/png;base64" in md -def test_apply_code_inline(editor): - editor.from_markdown("alpha beta") - editor.selectAll() - editor.apply_code() - md = editor.to_markdown() - assert ("`" in md) or ("```" in md) - - -@pytest.mark.gui -def test_auto_close_code_fence(editor, qtbot): - # Place caret at start and type exactly `` then ` to trigger expansion - editor.setPlainText("") - qtbot.keyClicks(editor, "``") - qtbot.keyClicks(editor, "`") # third backtick triggers fence insertion - txt = editor.toPlainText() - assert "```" in txt and txt.count("```") >= 2 - - @pytest.mark.gui def test_checkbox_toggle_by_click(editor, qtbot): # Load a markdown checkbox @@ -143,3 +142,131 @@ def test_enter_on_empty_list_marks_empty(qtbot, editor): ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n") editor.keyPressEvent(ev) assert editor.toPlainText().startswith("- \n") + + +@pytest.mark.gui +def test_triple_backtick_autoexpands(editor, qtbot): + editor.from_markdown("") + press_backtick(qtbot, editor, 2) + press_backtick(qtbot, editor, 1) # triggers expansion + qtbot.wait(0) + + t = text(editor) + assert t.count("```") == 2 + assert t.startswith("```\n\n```") + assert t.endswith("\n") + # caret is on the blank line inside the block + assert editor.textCursor().blockNumber() == 1 + assert lines_keep(editor)[1] == "" + + +@pytest.mark.gui +def test_toolbar_inserts_block_on_own_lines(editor, qtbot): + editor.from_markdown("hello") + editor.moveCursor(QTextCursor.End) + editor.apply_code() # action + qtbot.wait(0) + + t = text(editor) + assert "hello```" not in t # never inline + assert t.startswith("hello\n```") + assert t.endswith("```\n") + # caret inside block (blank line) + assert editor.textCursor().blockNumber() == 2 + assert lines_keep(editor)[2] == "" + + +@pytest.mark.gui +def test_toolbar_inside_block_does_not_insert_inline_fences(editor, qtbot): + editor.from_markdown("") + editor.apply_code() # create a block (caret now on blank line inside) + qtbot.wait(0) + + pos_before = editor.textCursor().position() + t_before = text(editor) + + editor.apply_code() # pressing inside should be a no-op + qtbot.wait(0) + + assert text(editor) == t_before + assert editor.textCursor().position() == pos_before + + +@pytest.mark.gui +def test_toolbar_on_opening_fence_jumps_inside(editor, qtbot): + editor.from_markdown("") + editor.apply_code() + qtbot.wait(0) + + # Go to opening fence (line 0) + editor.moveCursor(QTextCursor.Start) + editor.apply_code() # should jump inside the block + qtbot.wait(0) + + assert editor.textCursor().blockNumber() == 1 + assert lines_keep(editor)[1] == "" + + +@pytest.mark.gui +def test_toolbar_on_closing_fence_jumps_out(editor, qtbot): + editor.from_markdown("") + editor.apply_code() + qtbot.wait(0) + + # Go to closing fence line (template: 0 fence, 1 blank, 2 fence, 3 blank-after) + editor.moveCursor(QTextCursor.End) # blank-after + editor.moveCursor(QTextCursor.Up) # closing fence + editor.moveCursor(QTextCursor.StartOfLine) + + editor.apply_code() # jump to the line after the fence + qtbot.wait(0) + + # Now on the blank line after the block + assert editor.textCursor().block().text() == "" + assert editor.textCursor().block().previous().text().strip() == "```" + + +@pytest.mark.gui +def test_down_escapes_from_last_code_line(editor, qtbot): + editor.from_markdown("```\nLINE\n```\n") + # Put caret at end of "LINE" + editor.moveCursor(QTextCursor.Start) + editor.moveCursor(QTextCursor.Down) # "LINE" + editor.moveCursor(QTextCursor.EndOfLine) + + qtbot.keyPress(editor, Qt.Key_Down) # hop after closing fence + qtbot.wait(0) + + # caret now on the blank line after the fence + assert editor.textCursor().block().text() == "" + assert editor.textCursor().block().previous().text().strip() == "```" + + +@pytest.mark.gui +def test_down_on_closing_fence_at_eof_creates_line(editor, qtbot): + editor.from_markdown("```\ncode\n```") # no trailing newline + # caret on closing fence line + editor.moveCursor(QTextCursor.End) + editor.moveCursor(QTextCursor.StartOfLine) + + qtbot.keyPress(editor, Qt.Key_Down) # should append newline and move there + qtbot.wait(0) + + # Do NOT use splitlines() here—preserve trailing blank line + assert text(editor).endswith("\n") + assert editor.textCursor().block().text() == "" # on the new blank line + assert editor.textCursor().block().previous().text().strip() == "```" + + +@pytest.mark.gui +def test_no_orphan_two_backticks_lines_after_edits(editor, qtbot): + editor.from_markdown("") + # create a block via typing + press_backtick(qtbot, editor, 3) + qtbot.keyClicks(editor, "x") + qtbot.keyPress(editor, Qt.Key_Down) # escape + editor.apply_code() # add second block via toolbar + qtbot.wait(0) + + # ensure there are no stray "``" lines + assert not any(ln.strip() == "``" for ln in lines_keep(editor))