Fixes for code block background painting, and add line numbers to Code Editor dialog
This commit is contained in:
parent
dc1046632c
commit
9db4cda8cc
4 changed files with 192 additions and 26 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue