你如何用 PyQt5 在列(或行)之间画线?

How do you draw lines between columns (or rows) with PyQt5?

我一直在努力学习 PyQt5 来重新实现我用 Tkinter 做的应用程序,但有一些设计差异。由于它不是一个复杂的应用程序,我想让它具有类似于 GitHub 桌面上的这个小 window 的样式(window 左侧的选项,其余的在剩下的 space):

我知道我的颜色现在看起来不太好,但我可以稍后处理。但是,我还没有找到如何绘制与这些相似的 lines/boxes,或者至少在我的 columns/rows.

之间的划分中

这是我目前的情况:

import sys
from PyQt5.QtWidgets import QApplication, QLabel, QPushButton, QWidget, QFileDialog, QGridLayout, QFrame
from PyQt5 import QtGui, QtCore


class Window(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle('Some Window Title')
        self.app = QApplication(sys.argv)
        self.screen = self.app.primaryScreen()
        self.screen_size = self.screen.size()
        self.screen_width = self.screen_size.width()
        self.screen_height = self.screen_size.height()

        self.setGeometry(
            self.screen_width * 0.1,
            self.screen_height * 0.1,
            self.screen_width * 0.8,
            self.screen_height * 0.8
        )
        self.setStyleSheet('background: #020a19;')

        self.grid = QGridLayout()
        self.grid.setVerticalSpacing(0)
        self.grid.setContentsMargins(0, 0, 0, 0)

        self.option_button_stylesheet =  '''
            QPushButton {
                background: #020a19;
                color: #c5cad4;
                border-color: #c5cad4;
                border: 0px 0px 0px 0px;
                border-style: outset;
                border-radius: 5px;
                font-size: 15px;
                font-weight: bold;
                padding: 10px;
                margin: 15px;
                width: 2px;

            }

            QPushButton:hover {
                background-color: #00384d;
            }
            '''

        self.placeholder_button_stylesheet = '''
            QPushButton {
                background: #020a19;
                color: #c5cad4;
                border-top: none;
                border-right: 1px;
                border-left:none;
                border-bottom: none;
                border-color: #c5cad4;
                border-style: outset;
                padding: 10px;
                margin: 0px;
                width: 2px;
                height: 100%;

            }

            QPushButton:hover {
                background-color: #020a19;
            }
            '''
        
        self.header_label = QLabel('Some Application')
        self.option_1_button = QPushButton('Option 1')
        self.option_2_button = QPushButton('Option 2')
        self.option_3_button = QPushButton('Option 3')        
        
        self.header_label.setStyleSheet(
            '''
            font-size: 25px;
            color: #c5cad4;
            padding-left: 10px;
            padding-top: 16px;
            padding-bottom: 20px;
            height: 10%;
            border-bottom: 1px solid #c5cad4;
            '''
        )
        self.header_label.setFixedHeight(120)
        self.grid.addWidget(self.header_label, 0, 0, 1, 2)
        

        self.option_1_button.setStyleSheet(self.option_button_stylesheet)
        self.option_1_button.setFixedWidth(200)
        self.option_1_button.setFixedHeight(100)
        self.grid.addWidget(self.option_1_button, 1, 0)

        self.option_2_button.setStyleSheet(self.option_button_stylesheet)
        self.option_2_button.setFixedWidth(200)
        self.option_2_button.setFixedHeight(100)
        self.grid.addWidget(self.option_2_button, 2, 0)

        self.option_3_button.setStyleSheet(self.option_button_stylesheet)
        self.option_3_button.setFixedWidth(200)
        self.option_3_button.setFixedHeight(100)
        self.grid.addWidget(self.option_3_button, 3, 0)

        self.grid.setRowStretch(4, 1)
        
        self.initUI()
        
        self.setLayout(self.grid)
        self.show()

    def initUI(self):
        
        self.greet_text = QLabel('Welcome')
        self.greet_text.setAlignment(QtCore.Qt.AlignCenter)
        self.greet_text.setStyleSheet(
            """
            font-size: 35px;
            color: #c5cad4;
            """
        )
        self.grid.addWidget(self.greet_text, 1, 1, 5, 1)


def run():
    window = Window()
    sys.exit(window.app.exec())

run()

如您所见,我使用 QWidget 作为我的 window 元素。我知道我可以使用 QMainWindow,但这改变了小部件的放置方式,我发现它更易于使用 QWidget。我也不需要这个应用程序的工具栏或类似的东西。

我怎样才能画出这些线?

Qt style sheets (QSS) 不提供这样的功能,因为它只能 style 特定的小部件,而不能考虑它们在其中的位置布局。这对您的情况很重要,因为您要做的是在布局项之间绘制“分隔线”。

理论上可以通过为容器小部件设置背景来实现这一点,该背景将成为线条颜色,让所有 child 小部件绘制其 完整 内容使用 不透明 颜色,并确保布局始终具有等于线条宽度的间距,但如果内部小部件不符合其完整尺寸,则它们使用 alpha 通道,或者添加一些拉伸或进一步的间距,结果会很难看。

一种可能性是使用 QWidget 子类,覆盖其 paintEvent() 并使用 QPainter 绘制这些线条。

我们的想法是循环遍历所有布局项目,并在“当前”项目和前一个项目之间画线。

在下面的示例中,我创建了一个实现上述概念的基本 QWidget 子类,具体取决于所使用的布局。

请注意,我必须对您的原始代码进行一些更改和更正:

  • 正如评论中已经指出的那样,现有的 QApplication 是强制性的以允许创建 QWidget,虽然可以使它成为 object 的属性(before 调用 super().__init__()), 概念上还是错误的;
  • 网格布局中的高度层次结构不应将单独的行和列用于其直接 child object,但应添加适当的 sub-layouts 或 child 小部件反而;在您的情况下,应该只有两行和两列:header 第一行有一个 2-column-span,菜单将位于第二行(索引 1)和第一列,第二列的右侧,菜单按钮将有自己的布局;
  • 非常不鼓励为 parent 小部件设置通用样式 sheet 属性,因为复杂的小部件(例如 QComboBox、QScrollBar 和滚动区域 children)需要 all 属性设置为正常工作;对于 parent 或应用程序,应始终避免使用 setStyleSheet('background: ...')
  • 样式 sheet 应在 parent 或应用程序上设置在许多小部件之间共享,并且应始终使用适当的 selectors
  • QSS width 属性 应谨慎使用,因为它可能会使小部件部分不可见且无法使用;
  • 如果您不想要任何边框,只需使用 border: none;;
  • 样式 sheet 尺寸仅支持绝对单位(参见 Length 属性 类型),忽略百分比值;
  • 设置固定的高度、边距和边距可能会导致意外行为;确保您仔细阅读 box model 并进行一些测试以了解其行为;
  • 类 不应在构造期间自动显示自己,因此 show() 不应在 __init__() 内调用(这不是特别“禁止”或不鼓励,但它仍然很好实践);
  • 应始终使用 if __name__ == '__main__': 块,尤其是在处理依赖于事件循环的程序或工具包时(像所有 UI 框架,如 Qt);

这里是你的原始代码的重写:

class LayoutLineWidget(QWidget):
    _borderColor = QColor('#c5cad4')

    def paintEvent(self, event):
        # QWidget subclasses *must* do this to properly use style sheets;
        # (see doc.qt.io/qt-5/stylesheet-reference.html#qwidget-widget)
        opt = QStyleOption()
        opt.initFrom(self)
        qp = QStylePainter(self)
        qp.drawPrimitive(QStyle.PE_Widget, opt)
        # end of default painting

        layout = self.layout()
        if not layout or layout.count() <= 1:
            return
        if layout.spacing() < 1:
            layout.setSpacing(1)
            return

        qp.setPen(self._borderColor)
        if isinstance(layout, QBoxLayout):
            lastGeo = layout.itemAt(0).geometry()
            if isinstance(layout, QVBoxLayout):
                for row in range(1, layout.count()):
                    newGeo = layout.itemAt(row).geometry()
                    y = (lastGeo.bottom() 
                         + (newGeo.y() - lastGeo.bottom()) // 2)
                    qp.drawLine(0, y, self.width(), y)
                    lastGeo = newGeo
            else:
                for col in range(1, layout.count()):
                    newGeo = layout.itemAt(row).geometry()
                    x = (lastGeo.right() 
                         + (newGeo.x() - lastGeo.right()) // 2)
                    qp.drawLine(x, 0, x, self.height())
                    lastGeo = newGeo
        elif isinstance(layout, QGridLayout):
            for i in range(layout.count()):
                row, col, rowSpan, colSpan = layout.getItemPosition(i)
                if not row and not col:
                    continue
                cellRect = layout.cellRect(row, col)
                if rowSpan:
                    cellRect |= layout.cellRect(row + rowSpan - 1, col)
                if colSpan:
                    cellRect |= layout.cellRect(row, col + colSpan - 1)
                if row:
                    aboveCell = layout.cellRect(row - 1, col)
                    y = (aboveCell.bottom() 
                         + (cellRect.y() - aboveCell.bottom()) // 2)
                    qp.drawLine(cellRect.x(), y, cellRect.right() + 1, y)
                if col:
                    leftCell = layout.cellRect(row, col - 1)
                    x = (leftCell.right() 
                         + (cellRect.x() - leftCell.right()) // 2)
                    qp.drawLine(x, cellRect.y(), x, cellRect.bottom() + 1)


class Window(LayoutLineWidget):
    def __init__(self):
        super().__init__()
        self.setStyleSheet('''
            Window {
                background: #020a19;
            }
            QLabel#header {
                qproperty-alignment: AlignCenter;
                font-size: 25px;
                color: #c5cad4;
                padding-left: 10px;
                padding-top: 16px;
                padding-bottom: 20px;
            }
            QWidget#content {
                border: 1px solid #c5cad4;
                border-radius: 5px;
            }
            QPushButton {
                background: #020a19;
                color: #c5cad4;
                border: none;
                border-radius: 5px;
                font-size: 15px;
                font-weight: bold;
                padding: 10px;
            }
            QPushButton:hover {
                background-color: #00384d;
            }
            QWidget#menu > QPushButton {
                width: 180px;
                height: 80px;
            }
        ''')

        mainLayout = QGridLayout(self)
        mainLayout.setContentsMargins(0, 0, 0, 0)
        mainLayout.setSpacing(0)

        self.header_label = QLabel('Some Application', objectName='header')
        self.header_label.setMinimumHeight(120)
        mainLayout.addWidget(self.header_label, 0, 0, 1, 2)

        menuContainer = QWidget(objectName='menu')
        mainLayout.addWidget(menuContainer)
        menuLayout = QVBoxLayout(menuContainer)
        menuLayout.setSpacing(15)

        self.option_1_button = QPushButton('Option 1')
        self.option_2_button = QPushButton('Option 2')
        self.option_3_button = QPushButton('Option 3')        

        menuLayout.addWidget(self.option_1_button)
        menuLayout.addWidget(self.option_2_button)
        menuLayout.addWidget(self.option_3_button)
        menuLayout.addStretch()

        rightLayout = QVBoxLayout()
        mainLayout.addLayout(rightLayout, 1, 1)

        self.content = QStackedWidget()
        self.content.setContentsMargins(40, 40, 40, 40)
        rightLayout.addWidget(self.content)
        rightLayout.addStretch(1)

        self.firstPage = LayoutLineWidget(objectName='content')
        self.content.addWidget(self.firstPage)
        firstPageLayout = QVBoxLayout(self.firstPage)
        spacing = sum(firstPageLayout.getContentsMargins()) // 2
        firstPageLayout.setSpacing(spacing)

        self.other_option_1_button = QPushButton('Other 1')
        self.other_option_2_button = QPushButton('Other 2')
        self.other_option_3_button = QPushButton('Other 3')

        firstPageLayout.addWidget(self.other_option_1_button)
        firstPageLayout.addWidget(self.other_option_2_button)
        firstPageLayout.addWidget(self.other_option_3_button)

        screen = QApplication.primaryScreen()
        rect = QRect(QPoint(), screen.size() * .8)
        rect.moveCenter(screen.geometry().center())
        self.setGeometry(rect)


if __name__ == '__main__':
    import sys
    app = QApplication(sys.argv)
    window = Window()
    window.show()
    sys.exit(app.exec())

这是视觉结果:

请注意,上面的代码会导致调用 paintEvent() 及其指令非常频繁,因此提供一些缓存始终是个好主意。一种可能是使用 QPicture,这是一种“QPainter 记录器”;由于它完全依赖于 C++ 实现,这允许通过绘制现有内容直到它被更改来优化绘画。

class LayoutLineWidget(QWidget):
    _borderColor = QColor('#c5cad4')
    _paintCache = None
    _redrawEvents = QEvent.LayoutRequest, QEvent.Resize

    def event(self, event):
        if event.type() in self._redrawEvents:
            self._paintCache = None
            self.update()
        return super().event(event)

    def paintEvent(self, event):
        # QWidget subclasses *must* do the following to properly use style sheets;
        # see https://doc.qt.io/qt-5/stylesheet-reference.html#qwidget-widget
        qp = QStylePainter(self)
        opt = QStyleOption()
        opt.initFrom(self)
        qp.drawPrimitive(QStyle.PE_Widget, opt)

        layout = self.layout()
        if not layout or layout.count() <= 1:
            return
        if layout.spacing() < 1:
            layout.setSpacing(1)
            return

        try:
            qp.drawPicture(0, 0, self._paintCache)
        except TypeError:
            self._rebuildPaintCache()
            qp.drawPicture(0, 0, self._paintCache)

    def _rebuildPaintCache(self):
        layout = self.layout()
        self._paintCache = QPicture()
        qp = QPainter(self._paintCache)
        # from this point, it's exactly the same as above
        qp.setPen(self._borderColor)
        if isinstance(layout, QBoxLayout):
        # ...

进一步说明:

  • 以上代码尚未针对具有不同 margin/padding 设置和复杂 row/column 网格布局跨度的小部件进行测试;它可能需要进一步修复;
  • QGridLayout 的一个隐藏功能是可以为每个网格“单元格”设置更多的小部件;虽然此功能对于复杂的布局很有用,但它有一个重要的缺点:每当 child 小部件或布局使用跨度时,布局间距将被忽略,因此 child 项目可能具有不一致的几何形状和上面的代码可能无法按预期工作;
  • 不要低估与盒子模型相关的方面,尤其是在处理尺寸时;
  • 字体大小应该以像素为单位设置,因为屏幕可以有不同的 DPI 设置;总是喜欢基于设备的单位:ptemex