{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Виджеты и макеты\n", "\n", "## Виджеты\n", "\n", "[Виджеты](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QWidget.html#more) --- атомы графического интерфейса. Виджет принимает события мыши, клавиатуры и другие события от системы, виджет рисует себя на экране.\n", "\n", "Виджеты не встроенные в родительский виджет называют окнами (`window`). Обычно у окон есть рамка и заголовочная планка, хотя и это тоже можно кастомизировать. Почти все встречаемые окна --- это или `QMainWindow` или диалоговое окно (обычно производный класс от класса [QDialog](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QDialog.html#qdialog)), про которые речь пойдет позже. \n", "\n", "Конструктор любого виджета принимает параметр `parent`, который и отвечает за то, будет ли этот виджет встроенным в родительский виджет (в качестве `parent` передан какой-то другой виджет) или независимым окном (значение по умолчанию `parent=None`). Родительский виджет можно назначить в любой момент методом [setParent](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QWidget.html#PySide6.QtWidgets.PySide6.QtWidgets.QWidget.setParent). Если виджет добавляется в макет (`layout`) другого виджета, то метод `setParent` вызывается автоматически и виджет становится встроенным.\n", "\n", "Большинство виджетов в `Qt` предназначено для использования в качестве встроенных дочерних виджетов (`child widget`).\n", "\n", "```{figure} ../_static/lecture_specific/qt/parent_child_widgets.png\n", "```\n", "\n", "Все виджеты наследуются от [QWidget](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QWidget.html#more) и наследуют от него ряд методов. Например, \n", "\n", "- [hide](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QWidget.html#PySide6.QtWidgets.PySide6.QtWidgets.QWidget.hide) (скрыть) и [show](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QWidget.html#PySide6.QtWidgets.PySide6.QtWidgets.QWidget.show) (показать);\n", "- [setEnabled](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QWidget.html#PySide6.QtWidgets.PySide6.QtWidgets.QWidget.setEnabled) (установить активным) и [setDisabled](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QWidget.html#PySide6.QtWidgets.PySide6.QtWidgets.QWidget.setDisabled) (установить неактивным);\n", "\n", "Следующий пример демонстрирует эффект применения этих методов. \n", "```python\n", "import sys\n", "from functools import partial\n", "\n", "from PySide6.QtGui import Qt\n", "from PySide6.QtWidgets import *\n", "\n", "\n", "class MainWindow(QMainWindow):\n", " def __init__(self):\n", " super().__init__()\n", " widget = QWidget()\n", " main_layout = QVBoxLayout()\n", " widget.setLayout(main_layout)\n", " self.setCentralWidget(widget)\n", "\n", " # top row\n", " button = QPushButton(\"Button\")\n", " label = QLabel(\"Label\")\n", " label.setAlignment(Qt.AlignCenter)\n", " edit = QLineEdit()\n", "\n", " # bottom row\n", " enable_button = QPushButton(\"Enable\")\n", " disable_button = QPushButton(\"Disable\")\n", " show_button = QPushButton(\"Show\")\n", " hide_button = QPushButton(\"Hide\")\n", "\n", " # layout\n", "\n", " top_layout = QHBoxLayout()\n", " top_layout.addWidget(button)\n", " top_layout.addWidget(label)\n", " top_layout.addWidget(edit)\n", " main_layout.addLayout(top_layout)\n", "\n", " bottom_layout = QHBoxLayout()\n", " bottom_layout.addWidget(enable_button)\n", " bottom_layout.addWidget(disable_button)\n", " bottom_layout.addWidget(show_button)\n", " bottom_layout.addWidget(hide_button)\n", " main_layout.addLayout(bottom_layout)\n", "\n", " # connections\n", " for widget in [button, label, edit]:\n", " enable_button.clicked.connect(partial(widget.setEnabled, True))\n", " disable_button.clicked.connect(partial(widget.setDisabled, True))\n", " show_button.clicked.connect(widget.show)\n", " hide_button.clicked.connect(widget.hide)\n", "\n", "\n", "app = QApplication(sys.argv)\n", "main_window = MainWindow()\n", "main_window.show()\n", "app.exec()\n", "```\n", "Сверху размещаются пара виджетов, видимость и активность которых управляется кнопками в нижнем ряду.\n", "```{figure} ../_static/lecture_specific/qt/disable_hide_1.png\n", "```\n", "Связывание сигналов происходит в этом цикле:\n", "```python\n", "for widget in [button, label, edit]:\n", " enable_button.clicked.connect(partial(widget.setEnabled, True))\n", " disable_button.clicked.connect(partial(widget.setDisabled, True))\n", " show_button.clicked.connect(widget.show)\n", " hide_button.clicked.connect(widget.hide)\n", "```\n", "Так, как методы `setEnabled` и `setDisabled` принимают в качестве параметра `True` или `False`, то в качестве слота передаётся не сам метод, а объект [partial](https://docs.python.org/3/library/functools.html#functools.partial) из модуля [functools](https://docs.python.org/3/library/functools.html#), связывающий первый и единственный параметр метода со значением `True`.\n", "\n", "Нажатие на кнопку `Disable` деактивирует все виджеты в верхней строке.\n", "```{figure} ../_static/lecture_specific/qt/disable_hide_2.png\n", "```\n", "\n", "Нажатие на кнопку `Hide` скроет все виджеты в верхней строке.\n", "```{figure} ../_static/lecture_specific/qt/disable_hide_3.png\n", "```\n", "\n", "```{note}\n", "Элементы нижней строки виджетов подтягиваются наверх при скрытии элементов верхней строки, так как эти строки виджетов помещены в вертикальный `layout`, который динамически старается выделить виджетам как можно больше пространства.\n", "```\n", "\n", "## Макеты\n", "\n", "Чтобы располагать виджеты внутри главного окна или внутри другого виджета, обычно используют макеты (`layout`). Чтобы изучить особенности ряда встроенных макетов определим виджет `ColorWidget`, который принимает цвет в качестве параметра при создании и заливает свой фон полностью указанным цветом. Добиться такого эффекта можно, например, следующим кодом.\n", "```python\n", "from PySide6.QtGui import QColor, QPalette\n", "from PySide6.QtWidgets import QWidget\n", "\n", "rainbow_colors = [\"Red\", \"Orange\", \"Yellow\", \"Green\", \"Blue\", \"Magenta\", \"Violet\"]\n", "\n", "\n", "class ColorWidget(QWidget):\n", " def __init__(self, color, parent=None):\n", " super().__init__(parent=parent)\n", " self.setAutoFillBackground(True)\n", " palette = self.palette()\n", " palette.setColor(QPalette.Window, QColor(color))\n", " self.setPalette(palette)\n", "```\n", "Единственная функция этого виджета --- занимать пространство, чтобы иллюстрировать, как работает тот или иной макет. Во всех следующих примерах предполагается, что файл с этим исходным кодом располагается в той же папке с именем `colorwidget.py`.\n", "\n", "### Без макета\n", "\n", "Прежде чем изучать предлагаемые `Qt` макеты, покажем в чем их польза. Вообще говоря, без макетов можно обойтись. Тогда необходимо придерживаться следующих правил:\n", "- при создании (или после создания методом [setParent]([setParent](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QWidget.html#PySide6.QtWidgets.PySide6.QtWidgets.QWidget.setParent))) виджета указывать в качестве родителя (`parent`) виджет, внутри которого необходимо отображать создаваемый виджет;\n", "- вызывать метод [setGeometry](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QWidget.html#PySide6.QtWidgets.PySide6.QtWidgets.QWidget.setGeometry) и передать ему в качестве аргумента:\n", " - координаты $x$ и $y$ левого верхнего угла в системе координат родительского виджета (весь экран для виджет без родителя) в пикселях;\n", " - высоту $h$ и ширину $w$ виджета в пикселях. \n", "\n", "В качестве примера создадим тройку виджетов в таком стиле.\n", "```python\n", "import sys\n", "\n", "from PySide6.QtWidgets import *\n", "\n", "from colorwidget import ColorWidget\n", "\n", "\n", "class MainWindow(QMainWindow):\n", " def __init__(self):\n", " super().__init__()\n", " self.setGeometry(0, 0, 300, 300)\n", "\n", " red = ColorWidget(\"Red\", parent=self)\n", " red.setGeometry(0, 0, 100, 100)\n", "\n", " green = ColorWidget(\"Green\", parent=self)\n", " green.setGeometry(100, 100, 100, 100)\n", "\n", " blue = ColorWidget(\"Blue\", parent=self)\n", " blue.setGeometry(200, 200, 100, 100)\n", "\n", "\n", "app = QApplication(sys.argv)\n", "main_window = MainWindow()\n", "main_window.show()\n", "app.exec()\n", "```\n", "\n", "```{figure} ../_static/lecture_specific/qt/no_layout.png\n", "```\n", "\n", "Есть несколько недостатков такого подхода, среди которых:\n", "- при указании координат можно ошибиться с координатами;\n", "- положения и размеры таких виджетов фиксированы в абсолютных единицах измерения --- в пикселях, а значит на экране с другим разрешением такое приложение может выглядеть хуже;\n", "- изменение размеров родительского виджета по умолчанию никак не повлияет на размеры и положения его дочерних виджетов.\n", "\n", "Все эти недостатки можно нивелировать, используя макеты.\n", "\n", "### Центральный виджет\n", "\n", "У главного окна `QMainWindow` имеется свой уникальный макет, который позволяет легко добавлять к нему [меню](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QMainWindow.html#creating-menus), [панель инструментов](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QMainWindow.html#creating-toolbars), [закрепляемые виджеты](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QMainWindow.html#creating-dock-widgets) и другое. В связи с этим у `QMainWindow` нельзя поменять макет на произвольный другой и, чтобы добавлять в него виджеты, необходимо использовать центральный виджет. Если виджет всего один, то можно прямо его и установить в качестве центрального. Код ниже демонстрирует это.\n", "\n", "```python\n", "import sys\n", "\n", "from PySide6.QtWidgets import *\n", "\n", "from colorwidget import ColorWidget\n", "\n", "\n", "class MainWindow(QMainWindow):\n", " def __init__(self):\n", " super().__init__()\n", "\n", " widget = ColorWidget(\"Blue\")\n", " self.setCentralWidget(widget)\n", "\n", "\n", "app = QApplication(sys.argv)\n", "main_window = MainWindow()\n", "main_window.show()\n", "app.exec()\n", "```\n", "\n", "```{figure} ../_static/lecture_specific/qt/central_widget.png\n", "```\n", "\n", "```{note}\n", "Заметьте, что этот виджет занимает всё доступное ему пространство даже при изменении размеров окна, т.к. метод `setCentralWidget` помещает его в макет главного окна.\n", "```\n", "\n", "Если виджетов в окне должно быть несколько, то чаще всего в качестве центрального виджета устанавливается пустой виджет `QWidget`, а уже внутри него размещаются необходимые виджеты в макете. При этом `Qt` предоставляет разные макеты на такие случаи.\n", "\n", "### Вертикальные и горизонтальные макеты. `QVBoxLayout` и `QHBoxLayout`\n", "\n", "[QVBoxLayout](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QVBoxLayout.html) располагает виджеты вертикальной `стопкой`, а [QHBoxLayout](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QHBoxLayout.html) --- горизонтальной. Их работа уже коротко демонстрировалась. \n", "\n", "Все макеты создаются пустым и затем в них добавляются виджеты методом [addWidget](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QBoxLayout.html#PySide6.QtWidgets.PySide6.QtWidgets.QBoxLayout.addWidget).\n", "\n", "Код ниже демонстрирует поведение макетов `QHBoxLayout` и `QVBoxLayout`, добавляя в них виджеты `ColorWidget` 7 цветов радуги. \n", "\n", "````{tabbed} QHBoxLayout\n", "```python\n", "import sys\n", "\n", "from PySide6.QtWidgets import *\n", "\n", "from colorwidget import ColorWidget, rainbow_colors\n", "\n", "\n", "class MainWindow(QMainWindow):\n", " def __init__(self):\n", " super().__init__()\n", " widget = QWidget()\n", " layout = QHBoxLayout()\n", " widget.setLayout(layout)\n", " self.setCentralWidget(widget)\n", "\n", " for color in rainbow_colors:\n", " layout.addWidget(ColorWidget(color))\n", "\n", "\n", "app = QApplication(sys.argv)\n", "main_window = MainWindow()\n", "main_window.show()\n", "app.exec()\n", "```\n", "```{figure} ../_static/lecture_specific/qt/horizontal_rainbow.png\n", "```\n", "\n", "````\n", "````{tabbed} QVBoxLayout\n", "```python\n", "import sys\n", "\n", "from PySide6.QtWidgets import *\n", "\n", "from colorwidget import ColorWidget, rainbow_colors\n", "\n", "\n", "class MainWindow(QMainWindow):\n", " def __init__(self):\n", " super().__init__()\n", " widget = QWidget()\n", " layout = QVBoxLayout()\n", " widget.setLayout(layout)\n", " self.setCentralWidget(widget)\n", "\n", " for color in rainbow_colors:\n", " layout.addWidget(ColorWidget(color))\n", "\n", "\n", "app = QApplication(sys.argv)\n", "main_window = MainWindow()\n", "main_window.show()\n", "app.exec()\n", "```\n", "```{figure} ../_static/lecture_specific/qt/vertical_rainbow.png\n", "```\n", "````\n", "\n", "Макет старается максимально распределить виджеты по всему доступному ему пространству. При распределении пространства он учитывает два фактора:\n", "- параметр `stretch`, который отвечает за то, какая доля свободного пространства какому элементу макета отведется;\n", "- политику размеров самого виджета и ограничения на размер самого виджета, которые можно выставить такими методами [setMinimumHeight](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QWidget.html#PySide6.QtWidgets.PySide6.QtWidgets.QWidget.setMinimumHeight), [setMaximumHeight](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QWidget.html#PySide6.QtWidgets.PySide6.QtWidgets.QWidget.setMaximumHeight), [setMinimumWidth](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QWidget.html#PySide6.QtWidgets.PySide6.QtWidgets.QWidget.setMinimumWidth) и [setMaximumWidth](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QWidget.html#PySide6.QtWidgets.PySide6.QtWidgets.QWidget.setMaximumWidth).\n", "\n", "Код ниже демонстрирует, как эти параметры управляют распределением пространства.\n", "\n", "```python\n", "import sys\n", "\n", "from PySide6.QtWidgets import *\n", "\n", "from colorwidget import ColorWidget\n", "\n", "\n", "class MainWindow(QMainWindow):\n", " def __init__(self):\n", " super().__init__()\n", " widget = QWidget()\n", " layout = QHBoxLayout()\n", " widget.setLayout(layout)\n", " self.setCentralWidget(widget)\n", "\n", " red = ColorWidget(\"Red\")\n", " red.setMinimumWidth(100)\n", " red.setMaximumWidth(200)\n", " violet = ColorWidget(\"Violet\")\n", " violet.setMinimumWidth(100)\n", " violet.setMaximumWidth(200)\n", "\n", " layout.addWidget(red, stretch=1)\n", " layout.addStretch(2)\n", " layout.addWidget(violet, stretch=1)\n", "\n", "\n", "app = QApplication(sys.argv)\n", "main_window = MainWindow()\n", "main_window.show()\n", "app.exec()\n", "```\n", "\n", "Если в один элемент вертикального макета необходимо встроить строку виджетов, то методом [addLayout](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QBoxLayout.html#PySide6.QtWidgets.PySide6.QtWidgets.QBoxLayout.addLayout) в него можно добавить горизонтальный макет (или макет любого другого типа) вместо виджета. Таким образом макеты могут быть встроенными друг в друга.\n", "\n", "### Макет-таблица. `QGridLayout`\n", "\n", "Макет [QGridLayout](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QGridLayout.html) позволяет располагать виджеты как-бы в ячейках таблицы. При этом один виджет может занимать несколько соседних ячеек и можно не заполнять все ячейки таблицы. \n", "\n", "Макет `QGridLayout` создаётся пустым. Виджеты добавляются методом [addWidget](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QGridLayout.html#PySide6.QtWidgets.PySide6.QtWidgets.QGridLayout.addWidget), принимающим первым параметром сам виджет, а остальными параметрами --- ячейки таблицы, которые он должен занимать:\n", "- если виджет `widget` должен занимать одну ячейку макета `grid_layout` на пересечении строки с номером `row` и столбца с номером `column`, то сигнатура вызова выглядит примерно следующим образом:\n", "```python\n", "grid_layout.addWidget(widget, row, column)\n", "```\n", "- если этот же виджет должен начинаться в ячейке на пересечении строки с номером `row` и столбца с номером `column` и должен занимать `row_span` ячеек по горизонтали и `column_span` ячеек по вертикали, то сигнатура вызова выглядит примерно следующим образом:\n", "```python\n", "grid_layout.addWidget(widget, row, column, row_span, column_span)\n", "```\n", "\n", "```{note}\n", "Нумерация строк идёт сверху вниз, столбцов слева справа и начинается с 1 в обоих случаях.\n", "```\n", "\n", "Следующий код демонстрирует некоторые возможности этого макета.\n", "\n", "```python\n", "import sys\n", "\n", "from PySide6.QtWidgets import *\n", "\n", "from colorwidget import ColorWidget\n", "\n", "\n", "class MainWindow(QMainWindow):\n", " def __init__(self):\n", " super().__init__()\n", " widget = QWidget()\n", " layout = QGridLayout()\n", " widget.setLayout(layout)\n", " self.setCentralWidget(widget)\n", "\n", " layout.addWidget(ColorWidget(\"Red\"), 1, 1)\n", " layout.addWidget(ColorWidget(\"Green\"), 2, 4)\n", " layout.addWidget(ColorWidget(\"Blue\"), 3, 1, 1, 2)\n", "\n", "\n", "app = QApplication(sys.argv)\n", "main_window = MainWindow()\n", "main_window.show()\n", "app.exec()\n", "```\n", "\n", "```{figure} ../_static/lecture_specific/qt/grid_layout.png\n", "```\n", "\n", "### Макет-анкета. `QFormLayout`\n", "\n", "Часто при создании интерфейса приложения необходимо расположить несколько виджетов с подписями друг под другом. Такая необходимость часто возникает, если интерфейс представляет собой форму, анкету и так далее.\n", "\n", "Добиться этого можно было бы и с помощью `QGridLayout` с двумя столбцами: один для виджетов, другой для их подписей. Но гораздо удобнее воспользоваться специальным макетом [QFormLayout](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QFormLayout.html). Добавление виджетов с названиями в него осуществляется методом [addRow](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QFormLayout.html#PySide6.QtWidgets.PySide6.QtWidgets.QFormLayout.addRow), которому первым параметром передаётся строка текста, а вторым виджет.\n", "\n", "```{figure} ../_static/lecture_specific/qt/form_layout.png\n", "```\n", "\n", "```python\n", "import sys\n", "\n", "from PySide6.QtWidgets import *\n", "\n", "\n", "class MainWindow(QMainWindow):\n", " def __init__(self):\n", " super().__init__()\n", " widget = QWidget()\n", " layout = QFormLayout()\n", " widget.setLayout(layout)\n", " self.setCentralWidget(widget)\n", "\n", " layout.addRow(\"Фамилия\", QLineEdit())\n", " layout.addRow(\"Имя\", QLineEdit())\n", " layout.addRow(\"Отчество\", QLineEdit())\n", " layout.addRow(\"Год рождения\", QSpinBox())\n", " layout.addRow(\"Гражданство РФ\", QCheckBox())\n", "\n", "\n", "app = QApplication(sys.argv)\n", "main_window = MainWindow()\n", "main_window.show()\n", "app.exec()\n", "```\n", "\n", "### Вкладки. Виджет `QTabWidget`\n", "\n", "Хотя [QTabWidget](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QTabWidget.html) и не является макетом, но так как принцип его действия очень похож, то он упоминается здесь. Этот виджет позволяет делать виджеты с вкладками, примером которых можно считать вкладки браузеров. Создаётся `QTabWidget` пустым, а добавление вкладок осуществляется методом [addTab](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QTabWidget.html#PySide6.QtWidgets.PySide6.QtWidgets.QTabWidget.addTab), который первым параметром принимает виджет. В простом варианте, вторым параметром передаётся строка с названием вкладки. В более сложном варианте это строка передаётся третьим параметром, а вторым передаётся иконка этой вкладки.\n", "\n", "Перечислим ещё пару полезных особенностей:\n", "- метод [setMovable](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QTabWidget.html#PySide6.QtWidgets.PySide6.QtWidgets.QTabWidget.setMovable) позволяет перемещать вкладки между собой;\n", "- метод [setTabsClosable](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QTabWidget.html#PySide6.QtWidgets.PySide6.QtWidgets.QTabWidget.setTabsClosable) добавляет кнопки закрытия вкладок, нажатие на которые излучает сигнал [tabCloseRequested](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QTabWidget.html#PySide6.QtWidgets.PySide6.QtWidgets.QTabWidget.tabCloseRequested);\n", "- соединить этот сигнал можно напрямую со слотом [removeTab](https://doc.qt.io/qtforpython/PySide6/QtWidgets/QTabWidget.html#PySide6.QtWidgets.PySide6.QtWidgets.QTabWidget.removeTab). \n", "\n", "\n", "\n", "```{figure} ../_static/lecture_specific/qt/tab_widget.png\n", "```\n", "\n", "Код ниже демонстрирует все перечисленные возможности `QTabWidget` на примере 7 закрываемых, перемещаемых вкладок всех цветов радуги.\n", "\n", "```python\n", "import sys\n", "\n", "from PySide6.QtWidgets import *\n", "\n", "from colorwidget import ColorWidget, rainbow_colors\n", "\n", "\n", "class MainWindow(QMainWindow):\n", " def __init__(self):\n", " super().__init__()\n", " self.tabs = QTabWidget()\n", " self.setCentralWidget(self.tabs)\n", "\n", " self.tabs.setMovable(True)\n", " self.tabs.setTabsClosable(True)\n", " self.tabs.tabCloseRequested.connect(self.tabs.removeTab)\n", "\n", " for color in rainbow_colors:\n", " self.tabs.addTab(ColorWidget(color), color)\n", "\n", "\n", "app = QApplication(sys.argv)\n", "main_window = MainWindow()\n", "main_window.show()\n", "app.exec()\n", "```" ] } ], "metadata": { "language_info": { "name": "python" }, "orig_nbformat": 4 }, "nbformat": 4, "nbformat_minor": 2 }