bouquin/bouquin/code_block_editor_dialog.py
Miguel Jacq 807d11ca75
All checks were successful
CI / test (push) Successful in 8m44s
Lint / test (push) Successful in 36s
Trivy / test (push) Successful in 17s
Add ability to collapse/expand sections of text
2025-12-23 17:18:02 +11:00

258 lines
9.2 KiB
Python

from __future__ import annotations
import re
from PySide6.QtCore import QRect, QSize, Qt
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPalette, QTextCursor
from PySide6.QtWidgets import (
QComboBox,
QDialog,
QDialogButtonBox,
QLabel,
QPlainTextEdit,
QVBoxLayout,
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)
# Allow Tab to insert indentation (not move focus between widgets)
self.setTabChangesFocus(False)
# Track whether we just auto-inserted indentation on Enter
self._last_enter_was_empty_indent = False
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()
self._update_tab_stop_width()
# ---- layout / sizing -------------------------------------------------
def setFont(self, font: QFont) -> None: # type: ignore[override]
"""Ensure tab width stays at 4 spaces when the font changes."""
super().setFont(font)
self._update_tab_stop_width()
def _update_tab_stop_width(self) -> None:
"""Set tab width to 4 spaces."""
metrics = QFontMetrics(self.font())
# Tab width = width of 4 space characters
self.setTabStopDistance(metrics.horizontalAdvance(" ") * 4)
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
def keyPressEvent(self, event): # type: ignore[override]
"""Auto-retain indentation on newlines (Tab/space) like the markdown editor.
Rules:
- If the current line is indented, Enter inserts a newline + the same indent.
- If the current line contains only indentation, a *second* Enter clears the indent
and starts an unindented line (similar to exiting bullets/checkboxes).
"""
if event.key() in (Qt.Key_Return, Qt.Key_Enter):
cursor = self.textCursor()
block_text = cursor.block().text()
indent = re.match(r"[ \t]*", block_text).group(0) # type: ignore[union-attr]
if indent:
rest = block_text[len(indent) :]
indent_only = rest.strip() == ""
if indent_only and self._last_enter_was_empty_indent:
# Second Enter on an indentation-only line: remove that line and
# start a fresh, unindented line.
cursor.select(QTextCursor.SelectionType.LineUnderCursor)
cursor.removeSelectedText()
cursor.insertText("\n")
self.setTextCursor(cursor)
self._last_enter_was_empty_indent = False
return
# First Enter: keep indentation
super().keyPressEvent(event)
self.textCursor().insertText(indent)
self._last_enter_was_empty_indent = True
return
# No indent -> normal Enter
self._last_enter_was_empty_indent = False
super().keyPressEvent(event)
return
# Any other key resets the empty-indent-enter flag
self._last_enter_was_empty_indent = False
super().keyPressEvent(event)
class CodeBlockEditorDialog(QDialog):
def __init__(
self, code: str, language: str | None, parent=None, allow_delete: bool = False
):
super().__init__(parent)
self.setWindowTitle(strings._("edit_code_block"))
self.setMinimumSize(650, 650)
self._code_edit = CodeEditorWithLineNumbers(self)
self._code_edit.setPlainText(code)
# Track whether the user clicked "Delete"
self._delete_requested = False
# 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)
if allow_delete:
delete_btn = buttons.addButton(
strings._("delete_code_block"),
QDialogButtonBox.ButtonRole.DestructiveRole,
)
delete_btn.clicked.connect(self._on_delete_clicked)
layout = QVBoxLayout(self)
layout.addWidget(QLabel(strings._("locale") + ":", self))
layout.addWidget(self._lang_combo)
layout.addWidget(self._code_edit)
layout.addWidget(buttons)
def _on_delete_clicked(self) -> None:
"""Mark this dialog as 'delete requested' and close as Accepted."""
self._delete_requested = True
self.accept()
def was_deleted(self) -> bool:
"""Return True if the user chose to delete the code block."""
return self._delete_requested
def code(self) -> str:
return self._code_edit.toPlainText()
def language(self) -> str | None:
text = self._lang_combo.currentText().strip()
return text or None