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.
* 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
* 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

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 ""
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):
"""Deserialize metadata from text."""

View file

@ -289,5 +289,6 @@
"friday": "Friday",
"saturday": "Saturday",
"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 re
from pathlib import Path
from typing import Optional, Tuple
from PySide6.QtGui import (
QFont,
@ -20,10 +21,11 @@ from PySide6.QtGui import (
QDesktopServices,
)
from PySide6.QtCore import Qt, QRect, QTimer, QUrl
from PySide6.QtWidgets import QTextEdit
from PySide6.QtWidgets import QDialog, QTextEdit
from .theme import ThemeManager
from .markdown_highlighter import MarkdownHighlighter
from .code_block_editor_dialog import CodeBlockEditorDialog
from . import strings
@ -247,6 +249,153 @@ class MarkdownEditor(QTextEdit):
]
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):
"""Apply proportional line spacing to the whole document."""
doc = self.document()
@ -637,45 +786,77 @@ class MarkdownEditor(QTextEdit):
def keyPressEvent(self, event):
"""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 ---
if event.text() == "`":
c = self.textCursor()
block = c.block()
in_code = self._is_inside_code_block(block)
is_fence_line = block.text().strip().startswith("```")
# --- 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()
pos_in_block = c.position() - block.position()
# text before caret on this line
before = line[:pos_in_block]
# If we've typed exactly two backticks at line start (or after whitespace),
# treat this backtick as the "third" and expand to a full fenced block.
# "before" currently contains whatever's before the *third* backtick.
# We trigger only when the line is (whitespace + "``") before the caret.
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()
fence_block = (
doc.findBlock(start).next().next()
) # third line = closing fence
self._ensure_escape_line_after_closing_fence(fence_block)
if doc is not None:
# Remove the two backticks that were already typed
start = block.position() + pos_in_block - 2
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
new_pos = start + 4 # after "```\n"
c.setPosition(new_pos)
self.setTextCursor(c)
# Move caret to where the code block should start
c.setPosition(start)
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
# 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:
c = self.textCursor()
b = c.block()
@ -686,7 +867,8 @@ class MarkdownEditor(QTextEdit):
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
# 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)
@ -707,7 +889,8 @@ class MarkdownEditor(QTextEdit):
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
# 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)
@ -877,9 +1060,11 @@ class MarkdownEditor(QTextEdit):
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:
super().keyPressEvent(event)
if not self._edit_code_block(current_block):
# Fallback if something is malformed
super().keyPressEvent(event)
return
# Check for list continuation
@ -1044,6 +1229,16 @@ class MarkdownEditor(QTextEdit):
event.accept()
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
super().mouseDoubleClickEvent(event)
@ -1118,95 +1313,106 @@ class MarkdownEditor(QTextEdit):
self.setFocus()
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()
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()
if doc is None:
return
block = c.block()
line = block.text()
pos_in_block = c.position() - block.position()
stripped = line.strip()
block = cursor.block()
# 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
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()
# --- Case 1: already in a code block -> just edit that block ---
if self._is_inside_code_block(block) or block.text().strip().startswith("```"):
self._edit_code_block(block)
return
# Outside any block → create a clean template on its own lines (never inline)
start_pos = c.position()
before = line[:pos_in_block]
# --- Case 2: creating a new block (optional selection) ---
if cursor.hasSelection():
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.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"
# Remove selection (if any) so we can insert the new fenced block
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()
# Put caret on the blank line inside the block
c.setPosition(start_pos + len(lead_break) + 4) # after "```\n"
self.setTextCursor(c)
# Find the opening fence block we just inserted
open_block = doc.findBlock(start_pos + len(lead_break))
if hasattr(self, "_update_code_block_row_backgrounds"):
self._update_code_block_row_backgrounds()
# Find the closing fence block
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._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()
@ -1393,15 +1599,12 @@ class MarkdownEditor(QTextEdit):
lang_menu = menu.addMenu(strings._("set_code_language"))
languages = [
"python",
"bash",
"php",
"javascript",
"html",
"css",
"sql",
"java",
"go",
"html",
"javascript",
"php",
"python",
]
for lang in languages:
action = QAction(lang.capitalize(), self)
@ -1412,6 +1615,12 @@ class MarkdownEditor(QTextEdit):
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
if self.textCursor().hasSelection():
menu.addAction(strings._("cut"), self.cut)

View file

@ -58,3 +58,38 @@ def fresh_db(tmp_db_cfg):
assert ok, "DB connect() should succeed"
yield db
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")
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("")
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)
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):
editor.from_markdown("hello")
editor.moveCursor(QTextCursor.End)
editor.apply_code() # </> action inserts fenced code block
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() == "```"
# With the new dialog-based implementation, and our test stub that accepts
# the dialog with empty code, no fenced code block is inserted.
assert "```" not in t
assert t == ""
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
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):
# Non-existent path should just return (early exit)
bad = tmp_path / "missing.png"