More fixes to code blocks
This commit is contained in:
parent
1b706dec18
commit
43c31a1d97
4 changed files with 280 additions and 37 deletions
|
|
@ -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
|
# 0.2.1.5
|
||||||
|
|
||||||
* Go back to font size 10 (I might add a switcher later)
|
* Go back to font size 10 (I might add a switcher later)
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,16 @@ class MarkdownEditor(QTextEdit):
|
||||||
finally:
|
finally:
|
||||||
self._updating = False
|
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):
|
def _update_code_block_row_backgrounds(self):
|
||||||
"""Paint a full-width background for each line that is in a fenced code block."""
|
"""Paint a full-width background for each line that is in a fenced code block."""
|
||||||
doc = self.document()
|
doc = self.document()
|
||||||
|
|
@ -354,7 +364,7 @@ class MarkdownEditor(QTextEdit):
|
||||||
edit.beginEditBlock()
|
edit.beginEditBlock()
|
||||||
edit.setPosition(start)
|
edit.setPosition(start)
|
||||||
edit.setPosition(start + 2, QTextCursor.KeepAnchor)
|
edit.setPosition(start + 2, QTextCursor.KeepAnchor)
|
||||||
edit.insertText("```\n\n```")
|
edit.insertText("```\n\n```\n")
|
||||||
edit.endEditBlock()
|
edit.endEditBlock()
|
||||||
|
|
||||||
# place caret on the blank line between the fences
|
# place caret on the blank line between the fences
|
||||||
|
|
@ -362,6 +372,52 @@ class MarkdownEditor(QTextEdit):
|
||||||
c.setPosition(new_pos)
|
c.setPosition(new_pos)
|
||||||
self.setTextCursor(c)
|
self.setTextCursor(c)
|
||||||
return
|
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
|
# Handle Enter key for smart list continuation AND code blocks
|
||||||
if event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter:
|
if event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter:
|
||||||
|
|
@ -580,25 +636,80 @@ class MarkdownEditor(QTextEdit):
|
||||||
self.setFocus()
|
self.setFocus()
|
||||||
|
|
||||||
def apply_code(self):
|
def apply_code(self):
|
||||||
"""Insert or toggle code block."""
|
"""Insert a fenced code block, or navigate fences without creating inline backticks."""
|
||||||
cursor = self.textCursor()
|
c = self.textCursor()
|
||||||
|
doc = self.document()
|
||||||
|
|
||||||
if cursor.hasSelection():
|
if c.hasSelection():
|
||||||
# Wrap selection in code fence
|
# Wrap selection and ensure exactly one newline after the closing fence
|
||||||
selected = cursor.selectedText()
|
selected = c.selectedText().replace("\u2029", "\n")
|
||||||
# selectedText() uses Unicode paragraph separator, replace with newline
|
c.insertText(f"```\n{selected.rstrip()}\n```\n")
|
||||||
selected = selected.replace("\u2029", "\n")
|
if hasattr(self, "_update_code_block_row_backgrounds"):
|
||||||
new_text = f"```\n{selected}\n```"
|
self._update_code_block_row_backgrounds()
|
||||||
cursor.insertText(new_text)
|
self.setFocus()
|
||||||
else:
|
return
|
||||||
# Insert code block template
|
|
||||||
cursor.insertText("```\n\n```")
|
|
||||||
cursor.movePosition(
|
|
||||||
QTextCursor.MoveOperation.Up, QTextCursor.MoveMode.MoveAnchor, 1
|
|
||||||
)
|
|
||||||
self.setTextCursor(cursor)
|
|
||||||
|
|
||||||
# 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()
|
self.setFocus()
|
||||||
|
|
||||||
def apply_heading(self, size: int):
|
def apply_heading(self, size: int):
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "bouquin"
|
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."
|
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
|
||||||
authors = ["Miguel Jacq <mig@mig5.net>"]
|
authors = ["Miguel Jacq <mig@mig5.net>"]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,29 @@ from bouquin.markdown_editor import MarkdownEditor
|
||||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
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
|
@pytest.fixture
|
||||||
def editor(app, qtbot):
|
def editor(app, qtbot):
|
||||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||||
ed = MarkdownEditor(themes)
|
ed = MarkdownEditor(themes)
|
||||||
qtbot.addWidget(ed)
|
qtbot.addWidget(ed)
|
||||||
ed.show()
|
ed.show()
|
||||||
|
qtbot.waitExposed(ed)
|
||||||
|
ed.setFocus()
|
||||||
return ed
|
return ed
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -56,24 +73,6 @@ def test_insert_image_from_path(editor, tmp_path):
|
||||||
assert "data:image/image/png;base64" in md
|
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
|
@pytest.mark.gui
|
||||||
def test_checkbox_toggle_by_click(editor, qtbot):
|
def test_checkbox_toggle_by_click(editor, qtbot):
|
||||||
# Load a markdown checkbox
|
# 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")
|
ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n")
|
||||||
editor.keyPressEvent(ev)
|
editor.keyPressEvent(ev)
|
||||||
assert editor.toPlainText().startswith("- \n")
|
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))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue