Code Blocks are now their own QDialog to try and reduce risk of getting trapped in / bleeding in/out of text in code blocks.
Some checks failed
CI / test (push) Failing after 5m47s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 24s

This commit is contained in:
Miguel Jacq 2025-11-29 10:10:51 +11:00
parent 7a207df0f3
commit 57f11abb99
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
7 changed files with 429 additions and 203 deletions

View file

@ -8,6 +8,7 @@
* Slightly fade the text of a checkbox line if the checkbox is checked. * Slightly fade the text of a checkbox line if the checkbox is checked.
* Fix weekend date colours being incorrect on theme change while app is running * Fix weekend date colours being incorrect on theme change while app is running
* Avoid capturing checkbox/bullet etc in the task text that would get offered as the 'note' when Pomodoro timer stops * Avoid capturing checkbox/bullet etc in the task text that would get offered as the 'note' when Pomodoro timer stops
* Code Blocks are now their own QDialog to try and reduce risk of getting trapped in / bleeding in/out of text in code blocks.
# 0.5.2 # 0.5.2

View file

@ -0,0 +1,58 @@
from __future__ import annotations
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QPlainTextEdit,
QDialogButtonBox,
QComboBox,
QLabel,
)
from . import strings
class CodeBlockEditorDialog(QDialog):
def __init__(self, code: str, language: str | None, parent=None):
super().__init__(parent)
self.setWindowTitle(strings._("edit_code_block"))
self.setMinimumSize(650, 650)
self._code_edit = QPlainTextEdit(self)
self._code_edit.setPlainText(code)
# Language selector (optional)
self._lang_combo = QComboBox(self)
languages = [
"",
"bash",
"css",
"html",
"javascript",
"php",
"python",
]
self._lang_combo.addItems(languages)
if language and language in languages:
self._lang_combo.setCurrentText(language)
# Buttons
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
parent=self,
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout = QVBoxLayout(self)
layout.addWidget(QLabel(strings._("locale") + ":", self))
layout.addWidget(self._lang_combo)
layout.addWidget(self._code_edit)
layout.addWidget(buttons)
def code(self) -> str:
return self._code_edit.toPlainText()
def language(self) -> str | None:
text = self._lang_combo.currentText().strip()
return text or None

View file

@ -348,7 +348,7 @@ class CodeBlockMetadata:
return "" return ""
items = [f"{k}:{v}" for k, v in sorted(self._block_languages.items())] items = [f"{k}:{v}" for k, v in sorted(self._block_languages.items())]
return "<!-- code-langs: " + ",".join(items) + " -->" return "<!-- code-langs: " + ",".join(items) + " -->\n"
def deserialize(self, text: str): def deserialize(self, text: str):
"""Deserialize metadata from text.""" """Deserialize metadata from text."""

View file

@ -289,5 +289,6 @@
"friday": "Friday", "friday": "Friday",
"saturday": "Saturday", "saturday": "Saturday",
"sunday": "Sunday", "sunday": "Sunday",
"day": "Day" "day": "Day",
"edit_code_block": "Edit code block"
} }

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import base64 import base64
import re import re
from pathlib import Path from pathlib import Path
from typing import Optional, Tuple
from PySide6.QtGui import ( from PySide6.QtGui import (
QFont, QFont,
@ -20,10 +21,11 @@ from PySide6.QtGui import (
QDesktopServices, QDesktopServices,
) )
from PySide6.QtCore import Qt, QRect, QTimer, QUrl from PySide6.QtCore import Qt, QRect, QTimer, QUrl
from PySide6.QtWidgets import QTextEdit from PySide6.QtWidgets import QDialog, QTextEdit
from .theme import ThemeManager from .theme import ThemeManager
from .markdown_highlighter import MarkdownHighlighter from .markdown_highlighter import MarkdownHighlighter
from .code_block_editor_dialog import CodeBlockEditorDialog
from . import strings from . import strings
@ -247,6 +249,153 @@ class MarkdownEditor(QTextEdit):
] ]
self.setExtraSelections(others + sels) self.setExtraSelections(others + sels)
def _find_code_block_bounds(
self, block: QTextBlock
) -> Optional[Tuple[QTextBlock, QTextBlock]]:
"""
Given a block that is either inside a fenced code block or on a fence,
return (opening_fence_block, closing_fence_block).
Returns None if we can't find a proper pair.
"""
if not block.isValid():
return None
def is_fence(b: QTextBlock) -> bool:
return b.isValid() and b.text().strip().startswith("```")
# If we're on a fence line, decide if it's opening or closing
if is_fence(block):
# If we're "inside" just before this fence, this one closes.
if self._is_inside_code_block(block.previous()):
close_block = block
open_block = block.previous()
while open_block.isValid() and not is_fence(open_block):
open_block = open_block.previous()
if not is_fence(open_block):
return None
return open_block, close_block
else:
# Treat as opening fence; search downward for the closing one.
open_block = block
close_block = open_block.next()
while close_block.isValid() and not is_fence(close_block):
close_block = close_block.next()
if not is_fence(close_block):
return None
return open_block, close_block
# Normal interior line: search up for opening fence, down for closing.
open_block = block.previous()
while open_block.isValid() and not is_fence(open_block):
open_block = open_block.previous()
if not is_fence(open_block):
return None
close_block = open_block.next()
while close_block.isValid() and not is_fence(close_block):
close_block = close_block.next()
if not is_fence(close_block):
return None
return open_block, close_block
def _get_code_block_text(
self, open_block: QTextBlock, close_block: QTextBlock
) -> str:
"""Return the inner text (between fences) as a normal '\\n'-joined string."""
lines = []
b = open_block.next()
while b.isValid() and b != close_block:
lines.append(b.text())
b = b.next()
return "\n".join(lines)
def _replace_code_block_text(
self, open_block: QTextBlock, close_block: QTextBlock, new_text: str
) -> None:
"""
Replace everything between the two fences with `new_text`.
Fences themselves are left untouched.
"""
doc = self.document()
if doc is None:
return
cursor = QTextCursor(doc)
# Start just after the opening fence's newline
start_pos = open_block.position() + len(open_block.text())
# End at the start of the closing fence
end_pos = close_block.position()
cursor.setPosition(start_pos)
cursor.setPosition(end_pos, QTextCursor.MoveMode.KeepAnchor)
cursor.beginEditBlock()
# Normalise trailing newline(s)
new_text = new_text.rstrip("\n")
if new_text:
cursor.removeSelectedText()
cursor.insertText("\n" + new_text + "\n")
else:
# Empty block keep one blank line inside the fences
cursor.removeSelectedText()
cursor.insertText("\n\n")
cursor.endEditBlock()
# Re-apply spacing and backgrounds
if hasattr(self, "_apply_code_block_spacing"):
self._apply_code_block_spacing()
if hasattr(self, "_update_code_block_row_backgrounds"):
self._update_code_block_row_backgrounds()
# Trigger rehighlight
if hasattr(self, "highlighter"):
self.highlighter.rehighlight()
def _edit_code_block(self, block: QTextBlock) -> bool:
"""Open a popup editor for the code block containing `block`.
Returns True if a dialog was shown (regardless of OK/Cancel),
False if no well-formed fenced block was found.
"""
bounds = self._find_code_block_bounds(block)
if not bounds:
return False
open_block, close_block = bounds
# Current language from metadata (if any)
lang = None
if hasattr(self, "_code_metadata"):
lang = self._code_metadata.get_language(open_block.blockNumber())
code_text = self._get_code_block_text(open_block, close_block)
dlg = CodeBlockEditorDialog(code_text, lang, parent=self)
result = dlg.exec()
if result != QDialog.DialogCode.Accepted:
# Dialog was shown but user cancelled; event is "handled".
return True
new_code = dlg.code()
new_lang = dlg.language()
# Update document text but keep fences
self._replace_code_block_text(open_block, close_block, new_code)
# Update metadata language if changed
if new_lang is not None:
if not hasattr(self, "_code_metadata"):
from .code_highlighter import CodeBlockMetadata
self._code_metadata = CodeBlockMetadata()
self._code_metadata.set_language(open_block.blockNumber(), new_lang)
if hasattr(self, "highlighter"):
self.highlighter.rehighlight()
return True
def _apply_line_spacing(self, height: float = 125.0): def _apply_line_spacing(self, height: float = 125.0):
"""Apply proportional line spacing to the whole document.""" """Apply proportional line spacing to the whole document."""
doc = self.document() doc = self.document()
@ -637,45 +786,77 @@ class MarkdownEditor(QTextEdit):
def keyPressEvent(self, event): def keyPressEvent(self, event):
"""Handle special key events for markdown editing.""" """Handle special key events for markdown editing."""
c = self.textCursor()
block = c.block()
# --- Auto-close code fences when typing the 3rd backtick at line start --- in_code = self._is_inside_code_block(block)
if event.text() == "`": is_fence_line = block.text().strip().startswith("```")
c = self.textCursor()
block = c.block() # --- NEW: 3rd backtick shortcut → open code block dialog ---
# Only when we're *not* already in a code block or on a fence line.
if event.text() == "`" and not (in_code or is_fence_line):
line = block.text() line = block.text()
pos_in_block = c.position() - block.position() pos_in_block = c.position() - block.position()
# text before caret on this line
before = line[:pos_in_block] before = line[:pos_in_block]
# If we've typed exactly two backticks at line start (or after whitespace), # "before" currently contains whatever's before the *third* backtick.
# treat this backtick as the "third" and expand to a full fenced block. # We trigger only when the line is (whitespace + "``") before the caret.
if before.endswith("``") and before.strip() == "``": if before.endswith("``") and before.strip() == "``":
start = (
block.position() + pos_in_block - 2
) # start of the two backticks
edit = QTextCursor(self.document())
edit.beginEditBlock()
edit.setPosition(start)
edit.setPosition(start + 2, QTextCursor.KeepAnchor)
edit.insertText("```\n\n```\n")
edit.endEditBlock()
# new opening fence block starts at 'start'
doc = self.document() doc = self.document()
fence_block = ( if doc is not None:
doc.findBlock(start).next().next() # Remove the two backticks that were already typed
) # third line = closing fence start = block.position() + pos_in_block - 2
self._ensure_escape_line_after_closing_fence(fence_block) edit = QTextCursor(doc)
edit.beginEditBlock()
edit.setPosition(start)
edit.setPosition(start + 2, QTextCursor.KeepAnchor)
edit.removeSelectedText()
edit.endEditBlock()
# place caret on the blank line between the fences # Move caret to where the code block should start
new_pos = start + 4 # after "```\n" c.setPosition(start)
c.setPosition(new_pos) self.setTextCursor(c)
self.setTextCursor(c)
# Now behave exactly like the </> toolbar button
self.apply_code()
return
# ------------------------------------------------------------
# If we're anywhere in a fenced code block (including the fences),
# treat the text as read-only and route edits through the dialog.
if in_code or is_fence_line:
key = event.key()
# Navigation keys that are safe to pass through.
nav_keys_no_down = (
Qt.Key.Key_Left,
Qt.Key.Key_Right,
Qt.Key.Key_Up,
Qt.Key.Key_Home,
Qt.Key.Key_End,
Qt.Key.Key_PageUp,
Qt.Key.Key_PageDown,
)
# Let these through:
# - pure navigation (except Down, which we handle specially later)
# - Enter/Return and Down, which are handled by dedicated logic below
if key in nav_keys_no_down:
super().keyPressEvent(event)
return return
# Step out of a code block with Down at EOF if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter, Qt.Key.Key_Down):
# Let the existing Enter/Down code see these.
pass
else:
# Any other key (Backspace, Delete, characters, Tab, etc.)
# opens the code-block editor instead of editing inline.
if not self._edit_code_block(block):
# Fallback if bounds couldn't be found for some reason.
super().keyPressEvent(event)
return
# --- Step out of a code block with Down at EOF ---
if event.key() == Qt.Key.Key_Down: if event.key() == Qt.Key.Key_Down:
c = self.textCursor() c = self.textCursor()
b = c.block() b = c.block()
@ -686,7 +867,8 @@ class MarkdownEditor(QTextEdit):
nb = bb.next() nb = bb.next()
return nb.isValid() and nb.text().strip().startswith("```") 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 # Case A: caret is on the line BEFORE the closing fence, at EOL
# → jump after the fence
if ( if (
self._is_inside_code_block(b) self._is_inside_code_block(b)
and pos_in_block >= len(line) and pos_in_block >= len(line)
@ -707,7 +889,8 @@ class MarkdownEditor(QTextEdit):
self._update_code_block_row_backgrounds() self._update_code_block_row_backgrounds()
return return
# Case B: caret is ON the closing fence, and it's EOF → create a line and move to it # Case B: caret is ON the closing fence, and it's EOF
# → create a line and move to it
if ( if (
b.text().strip().startswith("```") b.text().strip().startswith("```")
and self._is_inside_code_block(b) and self._is_inside_code_block(b)
@ -877,9 +1060,11 @@ class MarkdownEditor(QTextEdit):
return return
# Inside a code block (but not on a fence): newline stays code-style # Inside a code block (but not on a fence): open the popup editor
if block_state == 1: if block_state == 1:
super().keyPressEvent(event) if not self._edit_code_block(current_block):
# Fallback if something is malformed
super().keyPressEvent(event)
return return
# Check for list continuation # Check for list continuation
@ -1044,6 +1229,16 @@ class MarkdownEditor(QTextEdit):
event.accept() event.accept()
return return
cursor = self.cursorForPosition(event.pos())
block = cursor.block()
# If were on or inside a code block, open the editor instead
if self._is_inside_code_block(block) or block.text().strip().startswith("```"):
# Only swallow the double-click if we actually opened a dialog.
if not self._edit_code_block(block):
super().mouseDoubleClickEvent(event)
return
# Otherwise, let normal double-click behaviour happen # Otherwise, let normal double-click behaviour happen
super().mouseDoubleClickEvent(event) super().mouseDoubleClickEvent(event)
@ -1118,95 +1313,106 @@ class MarkdownEditor(QTextEdit):
self.setFocus() self.setFocus()
def apply_code(self): def apply_code(self):
"""Insert a fenced code block, or navigate fences without creating inline backticks.""" """
c = self.textCursor() Toolbar handler for the </> button.
- If the caret is on / inside an existing fenced block, open the editor for it.
- Otherwise open the editor prefilled with any selected text, then insert a new
fenced block containing whatever the user typed.
"""
cursor = self.textCursor()
doc = self.document() doc = self.document()
if doc is None:
if c.hasSelection():
# Wrap selection and ensure exactly one newline after the closing fence
selected = c.selectedText().replace("\u2029", "\n")
start_block = c.block()
c.insertText(f"```\n{selected.rstrip()}\n```\n")
# closing fence is the block just before the current one
fence_block = start_block.next()
while fence_block.isValid() and not fence_block.text().strip().startswith(
"```"
):
fence_block = fence_block.next()
if fence_block.isValid():
self._ensure_escape_line_after_closing_fence(fence_block)
if hasattr(self, "_update_code_block_row_backgrounds"):
self._update_code_block_row_backgrounds()
# tighten spacing for the new code block
self._apply_code_block_spacing()
self.setFocus()
return return
block = c.block() block = cursor.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 # --- Case 1: already in a code block -> just edit that block ---
if stripped.startswith("```"): if self._is_inside_code_block(block) or block.text().strip().startswith("```"):
# Is this fence opening or closing? (look at blocks above) self._edit_code_block(block)
inside_before = self._is_inside_code_block(block.previous())
if inside_before:
# This fence closes the block → ensure a line after, then move there
self._ensure_escape_line_after_closing_fence(block)
endpos = block.position() + len(line)
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 return
# Outside any block → create a clean template on its own lines (never inline) # --- Case 2: creating a new block (optional selection) ---
start_pos = c.position() if cursor.hasSelection():
before = line[:pos_in_block] start_pos = cursor.selectionStart()
end_pos = cursor.selectionEnd()
# QTextEdit joins lines with U+2029 in selectedText()
initial_code = cursor.selectedText().replace("\u2029", "\n")
else:
start_pos = cursor.position()
end_pos = start_pos
initial_code = ""
# Let the user type/edit the code in the popup first
dlg = CodeBlockEditorDialog(initial_code, language=None, parent=self)
if dlg.exec() != QDialog.DialogCode.Accepted:
return
code_text = dlg.code()
language = dlg.language()
# Don't insert an entirely empty block
if not code_text.strip():
return
code_text = code_text.rstrip("\n")
edit = QTextCursor(doc) edit = QTextCursor(doc)
edit.beginEditBlock() edit.beginEditBlock()
# If there is text before the caret on the line, start the block on a new line # Remove selection (if any) so we can insert the new fenced block
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.setPosition(start_pos)
edit.insertText(insert) edit.setPosition(end_pos, QTextCursor.MoveMode.KeepAnchor)
edit.removeSelectedText()
# Work out whether we're mid-line and need to break before the fence
block = doc.findBlock(start_pos)
line = block.text()
pos_in_block = start_pos - block.position()
before = line[:pos_in_block]
# If there's text before the caret on this line, put the fence on a new line
lead_break = "\n" if before else ""
insert_str = f"{lead_break}```\n{code_text}\n```\n"
edit.setPosition(start_pos)
edit.insertText(insert_str)
edit.endEditBlock() edit.endEditBlock()
# Put caret on the blank line inside the block # Find the opening fence block we just inserted
c.setPosition(start_pos + len(lead_break) + 4) # after "```\n" open_block = doc.findBlock(start_pos + len(lead_break))
self.setTextCursor(c)
if hasattr(self, "_update_code_block_row_backgrounds"): # Find the closing fence block
self._update_code_block_row_backgrounds() close_block = open_block.next()
while close_block.isValid() and not close_block.text().strip().startswith(
"```"
):
close_block = close_block.next()
# tighten spacing for the new code block if close_block.isValid():
# Make sure there's always at least one line *after* the block
self._ensure_escape_line_after_closing_fence(close_block)
# Store language metadata if the user chose one
if language is not None:
if not hasattr(self, "_code_metadata"):
from .code_highlighter import CodeBlockMetadata
self._code_metadata = CodeBlockMetadata()
self._code_metadata.set_language(open_block.blockNumber(), language)
# Refresh visuals
self._apply_code_block_spacing() self._apply_code_block_spacing()
self._update_code_block_row_backgrounds()
if hasattr(self, "highlighter"):
self.highlighter.rehighlight()
# Put caret just after the code block so the user can keep writing normal text
after_block = close_block.next() if close_block.isValid() else None
if after_block and after_block.isValid():
cursor = self.textCursor()
cursor.setPosition(after_block.position())
self.setTextCursor(cursor)
self.setFocus() self.setFocus()
@ -1393,15 +1599,12 @@ class MarkdownEditor(QTextEdit):
lang_menu = menu.addMenu(strings._("set_code_language")) lang_menu = menu.addMenu(strings._("set_code_language"))
languages = [ languages = [
"python",
"bash", "bash",
"php",
"javascript",
"html",
"css", "css",
"sql", "html",
"java", "javascript",
"go", "php",
"python",
] ]
for lang in languages: for lang in languages:
action = QAction(lang.capitalize(), self) action = QAction(lang.capitalize(), self)
@ -1412,6 +1615,12 @@ class MarkdownEditor(QTextEdit):
menu.addSeparator() menu.addSeparator()
edit_action = QAction(strings._("edit_code_block"), self)
edit_action.triggered.connect(lambda: self._edit_code_block(block))
menu.addAction(edit_action)
menu.addSeparator()
# Add standard context menu actions # Add standard context menu actions
if self.textCursor().hasSelection(): if self.textCursor().hasSelection():
menu.addAction(strings._("cut"), self.cut) menu.addAction(strings._("cut"), self.cut)

View file

@ -58,3 +58,38 @@ def fresh_db(tmp_db_cfg):
assert ok, "DB connect() should succeed" assert ok, "DB connect() should succeed"
yield db yield db
db.close() db.close()
@pytest.fixture(autouse=True)
def _stub_code_block_editor_dialog(monkeypatch):
"""
In tests, replace the interactive CodeBlockEditorDialog with a tiny stub
that never shows a real QDialog and never blocks on exec().
"""
import bouquin.markdown_editor as markdown_editor
from PySide6.QtWidgets import QDialog
class _TestCodeBlockEditorDialog:
def __init__(self, code: str, language: str | None, parent=None):
# Simulate what the real dialog would “start with”
self._code = code
self._language = language
def exec(self) -> int:
# Pretend the user clicked OK immediately.
# (If you prefer “Cancel by default”, return Rejected instead.)
return QDialog.DialogCode.Accepted
def code(self) -> str:
# In tests we just return the initial code unchanged.
return self._code
def language(self) -> str | None:
# Ditto for language.
return self._language
# MarkdownEditor imported CodeBlockEditorDialog into its own module,
# so patch that name everything in MarkdownEditor will use this stub.
monkeypatch.setattr(
markdown_editor, "CodeBlockEditorDialog", _TestCodeBlockEditorDialog
)

View file

@ -164,81 +164,22 @@ def test_enter_on_empty_list_marks_empty(qtbot, editor):
assert editor.toPlainText().startswith("\u2022 \n") assert editor.toPlainText().startswith("\u2022 \n")
def test_triple_backtick_autoexpands(editor, qtbot): def test_triple_backtick_triggers_code_dialog_but_no_block_on_empty_code(editor, qtbot):
# Start empty
editor.from_markdown("") editor.from_markdown("")
press_backtick(qtbot, editor, 2) press_backtick(qtbot, editor, 2)
press_backtick(qtbot, editor, 1) # triggers expansion press_backtick(qtbot, editor, 1) # triggers the 3rd-backtick shortcut
qtbot.wait(0) qtbot.wait(0)
t = text(editor) 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] == ""
# The two typed backticks should have been removed
assert "`" not in t
def test_toolbar_inserts_block_on_own_lines(editor, qtbot): # With the new dialog-based implementation, and our test stub that accepts
editor.from_markdown("hello") # the dialog with empty code, no fenced code block is inserted.
editor.moveCursor(QTextCursor.End) assert "```" not in t
editor.apply_code() # </> action inserts fenced code block assert t == ""
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] == ""
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
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] == ""
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() == "```"
def test_down_escapes_from_last_code_line(editor, qtbot): def test_down_escapes_from_last_code_line(editor, qtbot):
@ -522,25 +463,6 @@ def test_apply_italic_and_strike(editor):
assert editor.textCursor().position() == len(editor.toPlainText()) - 2 assert editor.textCursor().position() == len(editor.toPlainText()) - 2
def test_apply_code_inline_block_navigation(editor):
# Selection case -> fenced block around selection
editor.setPlainText("code")
c = editor.textCursor()
c.select(QTextCursor.SelectionType.Document)
editor.setTextCursor(c)
editor.apply_code()
assert "```\ncode\n```\n" in editor.toPlainText()
# No selection, at EOF with no following block -> creates block and extra newline path
editor.setPlainText("before")
editor.moveCursor(QTextCursor.MoveOperation.End)
editor.apply_code()
t = editor.toPlainText()
assert t.endswith("before\n```\n\n```\n")
# Caret should be inside the code block blank line
assert editor.textCursor().position() == len("before\n") + 4
def test_insert_image_from_path_invalid_returns(editor_hello, tmp_path): def test_insert_image_from_path_invalid_returns(editor_hello, tmp_path):
# Non-existent path should just return (early exit) # Non-existent path should just return (early exit)
bad = tmp_path / "missing.png" bad = tmp_path / "missing.png"