208 lines
7.1 KiB
Python
208 lines
7.1 KiB
Python
from __future__ import annotations
|
|
|
|
from PySide6.QtCore import QRect, QSize, Qt
|
|
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPalette
|
|
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)
|
|
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
|
|
|
|
|
|
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
|