diff --git a/bouquin/code_block_editor_dialog.py b/bouquin/code_block_editor_dialog.py index 7b7b1b0..af1c99f 100644 --- a/bouquin/code_block_editor_dialog.py +++ b/bouquin/code_block_editor_dialog.py @@ -1,5 +1,8 @@ from __future__ import annotations +from PySide6.QtCore import QSize, QRect, Qt +from PySide6.QtGui import QPainter, QPalette, QColor, QFont, QFontMetrics + from PySide6.QtWidgets import ( QDialog, QVBoxLayout, @@ -7,11 +10,126 @@ from PySide6.QtWidgets import ( QDialogButtonBox, QComboBox, QLabel, + QWidget, ) from . import strings +class _LineNumberArea(QWidget): + def __init__(self, editor: "CodeEditorWithLineNumbers"): + super().__init__(editor) + self._editor = editor + + def sizeHint(self) -> QSize: # type: ignore[override] + return QSize(self._editor.line_number_area_width(), 0) + + def paintEvent(self, event): # type: ignore[override] + self._editor.line_number_area_paint_event(event) + + +class CodeEditorWithLineNumbers(QPlainTextEdit): + """QPlainTextEdit with a non-selectable line-number gutter on the left.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._line_number_area = _LineNumberArea(self) + + self.blockCountChanged.connect(self._update_line_number_area_width) + self.updateRequest.connect(self._update_line_number_area) + self.cursorPositionChanged.connect(self._line_number_area.update) + + self._update_line_number_area_width() + + # ---- layout / sizing ------------------------------------------------- + + def line_number_area_width(self) -> int: + # Enough digits for large-ish code blocks. + digits = max(2, len(str(max(1, self.blockCount())))) + fm = QFontMetrics(self._line_number_font()) + return fm.horizontalAdvance("9" * digits) + 8 + + def _line_number_font(self) -> QFont: + """Font to use for line numbers (slightly smaller than main text).""" + font = self.font() + if font.pointSize() > 0: + font.setPointSize(font.pointSize() - 1) + else: + # fallback for pixel-sized fonts + font.setPointSizeF(font.pointSizeF() * 0.9) + return font + + def _update_line_number_area_width(self) -> None: + margin = self.line_number_area_width() + self.setViewportMargins(margin, 0, 0, 0) + + def resizeEvent(self, event): # type: ignore[override] + super().resizeEvent(event) + cr = self.contentsRect() + self._line_number_area.setGeometry( + QRect(cr.left(), cr.top(), self.line_number_area_width(), cr.height()) + ) + + def _update_line_number_area(self, rect, dy) -> None: + if dy: + self._line_number_area.scroll(0, dy) + else: + self._line_number_area.update( + 0, rect.y(), self._line_number_area.width(), rect.height() + ) + + if rect.contains(self.viewport().rect()): + self._update_line_number_area_width() + + # ---- painting -------------------------------------------------------- + + def line_number_area_paint_event(self, event) -> None: + painter = QPainter(self._line_number_area) + painter.fillRect(event.rect(), self.palette().base()) + + # Use a slightly smaller font for numbers + painter.setFont(self._line_number_font()) + + # Faded colour: same blend used for completed-task text in + # MarkdownHighlighter (text colour towards background). + pal = self.palette() + text_fg = pal.color(QPalette.Text) + text_bg = pal.color(QPalette.Base) + t = 0.55 # same factor as completed_task_format + faded = QColor( + int(text_fg.red() * (1.0 - t) + text_bg.red() * t), + int(text_fg.green() * (1.0 - t) + text_bg.green() * t), + int(text_fg.blue() * (1.0 - t) + text_bg.blue() * t), + ) + painter.setPen(faded) + + block = self.firstVisibleBlock() + block_number = block.blockNumber() + top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top() + bottom = top + self.blockBoundingRect(block).height() + fm = self.fontMetrics() + line_height = fm.height() + right_margin = self._line_number_area.width() - 4 + + while block.isValid() and top <= event.rect().bottom(): + if block.isVisible() and bottom >= event.rect().top(): + number = str(block_number + 1) + painter.setPen(self.palette().text().color()) + painter.drawText( + 0, + int(top), + right_margin, + line_height, + Qt.AlignRight | Qt.AlignVCenter, + number, + ) + + block = block.next() + top = bottom + bottom = top + self.blockBoundingRect(block).height() + block_number += 1 + + class CodeBlockEditorDialog(QDialog): def __init__( self, code: str, language: str | None, parent=None, allow_delete: bool = False @@ -20,7 +138,7 @@ class CodeBlockEditorDialog(QDialog): self.setWindowTitle(strings._("edit_code_block")) self.setMinimumSize(650, 650) - self._code_edit = QPlainTextEdit(self) + self._code_edit = CodeEditorWithLineNumbers(self) self._code_edit.setPlainText(code) # Track whether the user clicked "Delete" diff --git a/bouquin/code_highlighter.py b/bouquin/code_highlighter.py index a56438a..3e8d8da 100644 --- a/bouquin/code_highlighter.py +++ b/bouquin/code_highlighter.py @@ -41,6 +41,7 @@ class CodeHighlighter: "not", "or", "pass", + "pprint", "print", "raise", "return", @@ -180,6 +181,7 @@ class CodeHighlighter: "unset", "use", "var", + "var_dump", "while", "xor", "yield", diff --git a/bouquin/markdown_editor.py b/bouquin/markdown_editor.py index 9e7bca6..286d84b 100644 --- a/bouquin/markdown_editor.py +++ b/bouquin/markdown_editor.py @@ -207,47 +207,81 @@ class MarkdownEditor(QTextEdit): 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.""" + def _update_code_block_row_backgrounds(self) -> None: + """Paint a full-width background behind each fenced ``` code block.""" + doc = self.document() if doc is None: return - sels = [] bg_brush = self.highlighter.code_block_format.background() + selections: list[QTextEdit.ExtraSelection] = [] + inside = False block = doc.begin() + block_start_pos: int | None = None + while block.isValid(): text = block.text() stripped = text.strip() is_fence = stripped.startswith("```") - paint_this_line = is_fence or inside - if paint_this_line: - sel = QTextEdit.ExtraSelection() - fmt = QTextCharFormat() - fmt.setBackground(bg_brush) - fmt.setProperty(QTextFormat.FullWidthSelection, True) - fmt.setProperty(QTextFormat.UserProperty, "codeblock_bg") - sel.format = fmt - - cur = QTextCursor(doc) - cur.setPosition(block.position()) - sel.cursor = cur - sels.append(sel) - if is_fence: - inside = not inside + if not inside: + # Opening fence: remember where this block starts + inside = True + block_start_pos = block.position() + else: + # Closing fence: create ONE selection from opening fence + # to the end of this closing fence block. + inside = False + if block_start_pos is not None: + sel = QTextEdit.ExtraSelection() + fmt = QTextCharFormat() + fmt.setBackground(bg_brush) + fmt.setProperty(QTextFormat.FullWidthSelection, True) + fmt.setProperty(QTextFormat.UserProperty, "codeblock_bg") + sel.format = fmt + + cursor = QTextCursor(doc) + cursor.setPosition(block_start_pos) + # extend to the end of the closing fence block + cursor.setPosition( + block.position() + block.length() - 1, + QTextCursor.MoveMode.KeepAnchor, + ) + sel.cursor = cursor + + selections.append(sel) + block_start_pos = None block = block.next() + # If the document ends while we're still inside a code block, + # extend the selection to the end of the document. + if inside and block_start_pos is not None: + sel = QTextEdit.ExtraSelection() + fmt = QTextCharFormat() + fmt.setBackground(bg_brush) + fmt.setProperty(QTextFormat.FullWidthSelection, True) + fmt.setProperty(QTextFormat.UserProperty, "codeblock_bg") + sel.format = fmt + + cursor = QTextCursor(doc) + cursor.setPosition(block_start_pos) + cursor.movePosition(QTextCursor.End, QTextCursor.MoveMode.KeepAnchor) + sel.cursor = cursor + + selections.append(sel) + + # Keep any other extraSelections (current-line highlight etc.) others = [ s for s in self.extraSelections() if s.format.property(QTextFormat.UserProperty) != "codeblock_bg" ] - self.setExtraSelections(others + sels) + self.setExtraSelections(others + selections) def _find_code_block_bounds( self, block: QTextBlock @@ -473,8 +507,8 @@ class MarkdownEditor(QTextEdit): def _apply_code_block_spacing(self): """ - Make all fenced code-block lines (including ``` fences) single-spaced. - Call this AFTER _apply_line_spacing(). + Make all fenced code-block lines (including ``` fences) single-spaced + and give them a solid background. """ doc = self.document() if doc is None: @@ -483,6 +517,8 @@ class MarkdownEditor(QTextEdit): cursor = QTextCursor(doc) cursor.beginEditBlock() + bg_brush = self.highlighter.code_block_format.background() + inside = False block = doc.begin() while block.isValid(): @@ -491,14 +527,22 @@ class MarkdownEditor(QTextEdit): is_fence = stripped.startswith("```") is_code_line = is_fence or inside + fmt = block.blockFormat() + if is_code_line: - fmt = block.blockFormat() + # Single spacing for code lines fmt.setLineHeight( 0.0, QTextBlockFormat.LineHeightTypes.SingleHeight.value, ) - cursor.setPosition(block.position()) - cursor.setBlockFormat(fmt) + # Solid background for the whole line (no seams) + fmt.setBackground(bg_brush) + else: + # Not in a code block → clear any stale background + fmt.clearProperty(QTextFormat.BackgroundBrush) + + cursor.setPosition(block.position()) + cursor.setBlockFormat(fmt) if is_fence: inside = not inside diff --git a/tests/conftest.py b/tests/conftest.py index 9c3d095..3911ada 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -70,7 +70,9 @@ def _stub_code_block_editor_dialog(monkeypatch): from PySide6.QtWidgets import QDialog class _TestCodeBlockEditorDialog: - def __init__(self, code: str, language: str | None, parent=None): + def __init__( + self, code: str, language: str | None, parent=None, allow_delete=False + ): # Simulate what the real dialog would “start with” self._code = code self._language = language