Search code examples
python-3.xpyside2

Pyside2 - Linenumbers in codeeditor incorrect when changed font family/size


I looked at this code editor example from the official Qt5 website https://doc.qt.io/qt-5/qtwidgets-widgets-codeeditor-example.html. It is written in C++ but I implemented it in Python using Pyside2.

The example code works fine as is, however, when I try to change the font family and size of the QPlainTextEdit things start getting messy. I've tried to tweak a lot of different fields like using the fontMetrics to determine to height etc.

Here is a minimal example to reproduce the problem

import sys
import signal
from PySide2.QtCore import Qt, QSize, QRect
from PySide2.QtGui import QPaintEvent, QPainter, QColor, QResizeEvent
from PySide2.QtWidgets import QWidget, QPlainTextEdit, QVBoxLayout
from PySide2 import QtCore
from PySide2.QtWidgets import QApplication


FONT_SIZE = 20
FONT_FAMILY = 'Source Code Pro'


class PlainTextEdit(QPlainTextEdit):
    def __init__(self, parent=None):
        super().__init__(parent)

        self.init_settings_font()

    def init_settings_font(self):
        font = self.document().defaultFont()

        font.setFamily(FONT_FAMILY)
        font.setFixedPitch(True)
        font.setPixelSize(FONT_SIZE)
        self.document().setDefaultFont(font)


class LineNumberArea(QWidget):
    TMP = dict()

    def __init__(self, editor):
        super().__init__(editor)
        self._editor = editor

        self._editor.blockCountChanged.connect(lambda new_count: self._update_margin())
        self._editor.updateRequest.connect(lambda rect, dy: self._update_request(rect, dy))

        self._update_margin()

    def width(self) -> int:
        # we use 1000 as a default size, so from 0-9999 this length will be applied
        _max = max(1000, self._editor.blockCount())
        digits = len(f'{_max}')
        space = self._editor.fontMetrics().horizontalAdvance('0', -1) * (digits + 1) + 6
        return QSize(space, 0).width()

    def _update_line_geometry(self):
        content_rect = self._editor.contentsRect()
        self._update_geometry(content_rect)

    def _update_geometry(self, content_rect: QRect):
        self.setGeometry(
            QRect(content_rect.left(), content_rect.top(), self.width(), content_rect.height())
        )

    def _update_margin(self):
        self._editor.setViewportMargins(self.width(), 0, 0, 0)

    def _update_request(self, rect: QRect, dy: int):
        self._update(0, rect.y(), self.width(), rect.height(), self._editor.contentsRect())

        if rect.contains(self._editor.viewport().rect()):
            self._update_margin()

    def _update(self, x: int, y: int, w: int, h: int, content_rect: QRect):
        self.update(x, y, w, h)
        self._update_geometry(content_rect)

    # override
    def resizeEvent(self, event: QResizeEvent) -> None:
        self._update_line_geometry()

    # override
    def paintEvent(self, event: QPaintEvent):
        painter = QPainter(self)
        area_color = QColor('darkgrey')

        # Clearing rect to update
        painter.fillRect(event.rect(), area_color)

        visible_block_num = self._editor.firstVisibleBlock().blockNumber()
        block = self._editor.document().findBlockByNumber(visible_block_num)
        top = self._editor.blockBoundingGeometry(block).translated(self._editor.contentOffset()).top()
        bottom = top + self._editor.blockBoundingRect(block).height()
        active_line_number = self._editor.textCursor().block().blockNumber() + 1

        # font_size = storage.get_setting(Constants.Editor_font_size).value
        font = self._editor.font()

        while block.isValid() and top <= event.rect().bottom():
            if block.isVisible() and bottom >= event.rect().top():
                number_to_draw = visible_block_num + 1

                if number_to_draw == active_line_number:
                    painter.setPen(QColor('black'))
                else:
                    painter.setPen(QColor('white'))

                font.setPixelSize(self._editor.document().defaultFont().pixelSize())
                painter.setFont(font)

                painter.drawText(
                    -5,
                    top,
                    self.width(),
                    self._editor.fontMetrics().height(),
                    int(Qt.AlignRight | Qt.AlignHCenter),
                    str(number_to_draw)
                )

            block = block.next()
            top = bottom
            bottom = top + self._editor.blockBoundingGeometry(block).height()
            visible_block_num += 1

        painter.end()

if __name__ == "__main__":
    app = QApplication(sys.argv)

    signal.signal(signal.SIGINT, signal.SIG_DFL)

    window = QWidget()
    layout = QVBoxLayout()
    editor = PlainTextEdit()
    line_num = LineNumberArea(editor)

    layout.addWidget(editor)
    window.setLayout(layout)

    window.show()

    sys.exit(app.exec_())

One of the biggest issues are that there seems to be a top margin offset in the plaintext exit which I'm unable to dynamically get in the linenumber widget. And when setting the editor font to the painter the numbers will not be drawn the same size!?

Does anyone know how to adjust the line numbers to the same horizontal level as the corresponding text and also get them to be the same size in a dynamic way, meaning that if the font will be set to something else they should all be adjusted automatically.


Solution

  • The problem comes from the fact that you're using two fonts for different purposes: the widget font and the document font.

    Each font has different aspects, and its alignment might differ if you consider those fonts as base for drawing coordinates.

    Since you're drawing with the document font but using the widget font as reference, the result is that you'll have drawing issues:

    • even with the same point size, different fonts are drawn at different heights, especially if the alignment of the text rectangle is not correct (also note that you used an inconsistent alignment, as Qt.AlignRight | Qt.AlignHCenter will always consider the right alignment and defaults to top alignment)
    • you're using the widget font metrics to set the text rectangle height, which differs from the document's metrics, so you'll limit the height of the drawing.

    Unlike other widgets, rich text editors in Qt have two font settings:

    • the widget font;
    • the (default) document font, which can be overridden by a QTextOption in the document;

    The document will always inherit the widget font (or application font) for the default, and this will also happen when setting the font for the widget at runtime, and even for the application (unless a font has been explicitly set for the widget).

    Setting the font for the editor is usually fine for simple situations, but you have to remember that fonts propagate, so children widget will inherit the font too.

    On the other hand, setting the default font for the document will not propagate to the children, but, as explained above, can be overridden by the application font if that's changed at runtime.

    The simplest solution, in your case, would be to set the font for the editor widget instead of the document. In this way you're sure that the LineNumberArea (which is the editor's child) will also inherit the same font. With this approach you don't even need to set the font of the painter, as it will always use the widget font.

    In case you want to use a different font and still keep correct alignment, you have to consider the baseline position of the font used for the document, and use that reference for the baseline of the widget font. In order to do that, you have to translate the block position with the difference of the ascent() of the two font metrics.