Task Menu Extension (Designer)

This example shows how to add custom widgets to Qt Designer, which can be launched with pyside6-designer, and to extend its built-in context menu.

The main mechanism is based on the QPyDesignerCustomWidgetCollection class that takes care of handling the registration.

More information can be found in Custom Widgets in Qt Designer.

Task Menu Extension Screenshot
from PySide6.QtCore import Qt, QPoint, QRect, QSize, Property, Slot
from PySide6.QtGui import QMouseEvent, QPainter, QPen
from PySide6.QtWidgets import QWidget


EMPTY = '-'
CROSS = 'X'
NOUGHT = 'O'
DEFAULT_STATE = "---------"


class TicTacToe(QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        self._state = DEFAULT_STATE
        self._turn_number = 0

    def minimumSizeHint(self):
        return QSize(200, 200)

    def sizeHint(self):
        return QSize(200, 200)

    def setState(self, new_state):
        self._turn_number = 0
        self._state = DEFAULT_STATE
        for position in range(min(9, len(new_state))):
            mark = new_state[position]
            if mark == CROSS or mark == NOUGHT:
                self._turn_number += 1
                self._change_state_at(position, mark)
            position += 1
        self.update()

    def state(self):
        return self._state

    @Slot()
    def clear_board(self):
        self._state = DEFAULT_STATE
        self._turn_number = 0
        self.update()

    def _change_state_at(self, pos, new_state):
        self._state = (self._state[:pos] + new_state
                       + self._state[pos + 1:])

    def mousePressEvent(self, event):
        if self._turn_number == 9:
            self.clear_board()
            return
        for position in range(9):
            cell = self._cell_rect(position)
            if cell.contains(event.position().toPoint()):
                if self._state[position] == EMPTY:
                    new_state = CROSS if self._turn_number % 2 == 0 else NOUGHT
                    self._change_state_at(position, new_state)
                    self._turn_number += 1
                    self.update()

    def paintEvent(self, event):
        painter = QPainter(self)
        painter.setRenderHint(QPainter.Antialiasing)

        painter.setPen(QPen(Qt.darkGreen, 1))
        painter.drawLine(self._cell_width(), 0,
                         self._cell_width(), self.height())
        painter.drawLine(2 * self._cell_width(), 0,
                         2 * self._cell_width(), self.height())
        painter.drawLine(0, self._cell_height(),
                         self.width(), self._cell_height())
        painter.drawLine(0, 2 * self._cell_height(),
                         self.width(), 2 * self._cell_height())

        painter.setPen(QPen(Qt.darkBlue, 2))

        for position in range(9):
            cell = self._cell_rect(position)
            if self._state[position] == CROSS:
                painter.drawLine(cell.topLeft(), cell.bottomRight())
                painter.drawLine(cell.topRight(), cell.bottomLeft())
            elif self._state[position] == NOUGHT:
                painter.drawEllipse(cell)

        painter.setPen(QPen(Qt.yellow, 3))

        for position in range(0, 8, 3):
            if (self._state[position] != EMPTY
                and self._state[position + 1] == self._state[position]
                and self._state[position + 2] == self._state[position]):
                y = self._cell_rect(position).center().y()
                painter.drawLine(0, y, self.width(), y)
                self._turn_number = 9

        for position in range(3):
            if (self._state[position] != EMPTY
                and self._state[position + 3] == self._state[position]
                and self._state[position + 6] == self._state[position]):
                x = self._cell_rect(position).center().x()
                painter.drawLine(x, 0, x, self.height())
                self._turn_number = 9

        if (self._state[0] != EMPTY and self._state[4] == self._state[0]
            and self._state[8] == self._state[0]):
            painter.drawLine(0, 0, self.width(), self.height())
            self._turn_number = 9

        if (self._state[2] != EMPTY and self._state[4] == self._state[2]
            and self._state[6] == self._state[2]):
            painter.drawLine(0, self.height(), self.width(), 0)
            self._turn_number = 9

        # QPainter needs an explicit end() in PyPy. This will become a context manager in 6.3.
        painter.end()

    def _cell_rect(self, position):
        h_margin = self.width() / 30
        v_margin = self.height() / 30
        row = int(position / 3)
        column = position - 3 * row
        pos = QPoint(column * self._cell_width() + h_margin,
                     row * self._cell_height() + v_margin)
        size = QSize(self._cell_width() - 2 * h_margin,
                     self._cell_height() - 2 * v_margin)
        return QRect(pos, size)

    def _cell_width(self):
        return self.width() / 3

    def _cell_height(self):
        return self.height() / 3

    state = Property(str, state, setState)
"""PySide6 port of the Qt Designer taskmenuextension example from Qt v6.x"""

import sys
from PySide6.QtWidgets import QApplication

from tictactoe import TicTacToe

if __name__ == '__main__':
    app = QApplication(sys.argv)
    window = TicTacToe()
    window.state = "-X-XO----"
    window.show()
    sys.exit(app.exec())
from tictactoe import TicTacToe
from tictactoeplugin import TicTacToePlugin

from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection

# Set PYSIDE_DESIGNER_PLUGINS to point to this directory and load the plugin


if __name__ == '__main__':
    QPyDesignerCustomWidgetCollection.addCustomWidget(TicTacToePlugin())
from tictactoe import TicTacToe
from tictactoetaskmenu import TicTacToeTaskMenuFactory

from PySide6.QtGui import QIcon
from PySide6.QtDesigner import (QExtensionManager,
    QDesignerCustomWidgetInterface)


DOM_XML = """
<ui language='c++'>
    <widget class='TicTacToe' name='ticTacToe'>
        <property name='geometry'>
            <rect>
                <x>0</x>
                <y>0</y>
                <width>200</width>
                <height>200</height>
            </rect>
        </property>
        <property name='state'>
            <string>-X-XO----</string>
        </property>
    </widget>
</ui>
"""


class TicTacToePlugin(QDesignerCustomWidgetInterface):
    def __init__(self):
        super().__init__()
        self._form_editor = None

    def createWidget(self, parent):
        t = TicTacToe(parent)
        return t

    def domXml(self):
        return DOM_XML

    def group(self):
        return ''

    def icon(self):
        return QIcon()

    def includeFile(self):
        return 'tictactoe'

    def initialize(self, form_editor):
        self._form_editor = form_editor
        manager = form_editor.extensionManager()
        iid = TicTacToeTaskMenuFactory.task_menu_iid()
        manager.registerExtensions(TicTacToeTaskMenuFactory(manager), iid)

    def isContainer(self):
        return False

    def isInitialized(self):
        return self._form_editor is not None

    def name(self):
        return 'TicTacToe'

    def toolTip(self):
        return 'Tic Tac Toe Example, demonstrating class QDesignerTaskMenuExtension (Python)'

    def whatsThis(self):
        return self.toolTip()
from tictactoe import TicTacToe

from PySide6.QtCore import QObject, Slot
from PySide6.QtGui import QAction
from PySide6.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout
from PySide6.QtDesigner import (QExtensionFactory, QPyDesignerTaskMenuExtension)


class TicTacToeDialog(QDialog):
    def __init__(self, parent):
        super().__init__(parent)
        layout = QVBoxLayout(self)
        self._ticTacToe = TicTacToe(self)
        layout.addWidget(self._ticTacToe)
        button_box = QDialogButtonBox(QDialogButtonBox.Ok
                                      | QDialogButtonBox.Cancel
                                      | QDialogButtonBox.Reset)
        button_box.accepted.connect(self.accept)
        button_box.rejected.connect(self.reject)
        reset_button = button_box.button(QDialogButtonBox.Reset)
        reset_button.clicked.connect(self._ticTacToe.clear_board)
        layout.addWidget(button_box)

    def set_state(self, new_state):
        self._ticTacToe.setState(new_state)

    def state(self):
        return self._ticTacToe.state


class TicTacToeTaskMenu(QPyDesignerTaskMenuExtension):
    def __init__(self, ticTacToe, parent):
        super().__init__(parent)
        self._ticTacToe = ticTacToe
        self._edit_state_action = QAction('Edit State...', None)
        self._edit_state_action.triggered.connect(self._edit_state)

    def taskActions(self):
        return [self._edit_state_action]

    def preferredEditAction(self):
        return self._edit_state_action

    @Slot()
    def _edit_state(self):
        dialog = TicTacToeDialog(self._ticTacToe)
        dialog.set_state(self._ticTacToe.state)
        if dialog.exec() == QDialog.Accepted:
            self._ticTacToe.state = dialog.state()


class TicTacToeTaskMenuFactory(QExtensionFactory):
    def __init__(self, extension_manager):
        super().__init__(extension_manager)

    @staticmethod
    def task_menu_iid():
        return 'org.qt-project.Qt.Designer.TaskMenu'

    def createExtension(self, object, iid, parent):
        if iid != TicTacToeTaskMenuFactory.task_menu_iid():
            return None
        if object.__class__.__name__ != 'TicTacToe':
            return None
        return TicTacToeTaskMenu(object, parent)