Making a simple painter in PySide6

  • The most important thing is drawing on canvas

  • Changing the brush size

  • Change brush color

  • Changing the canvas size

  • Undo/Redo function

  • Cleaning the canvas

  • Saving an image

Well, let's begin.

Project structure:

PaintNote (root of surtsov)

– res

— icons

– icons.qrc (resource file)

– rc_icons.py (resource file, converted so that the files can be referenced in code)

– app.py (entry point)

– PaintingArea.py (canvas)

– PaintingWindow.py (application window)

– PaintingWindow.ui

– Ui_PaintingWindow.py

Usually I create directories for ui files, and separately for ui_***.py generated from them (for example, ui_gen)

The entry point file itself:

app.py

import sys
from PySide6.QtWidgets import QApplication
from PaintNote.PaintWindow import PaintWindow


def main():
    app = QApplication(sys.argv)
    app.setApplicationName('MyPaint')

    window = PaintWindow()
    window.show()

    app.exec()

if __name__ == '__main__':
    main()

This file contains the entry point to the application. The object is declared QApplicationits setup is performed. Then the object of our painter window itself is declared and called. Then the application is launched thanks to the method exec().

The canvas that will be inserted as a widget in the painter window (I personally did it through QtDesignerreplaced the standard one QWidget on PaintingArea):

PaintingArea.py

from PySide6.QtWidgets import QWidget
from PySide6.QtGui import QPainter, QPen, QBrush, QImage
from PySide6.QtCore import Qt, QSize, QPoint, QRect


class PaintingArea(QWidget):
    def __init__(self, parent):
        super().__init__()

        self._parent = parent

        self.setMinimumSize(self._parent.size().width(), self._parent.size().height())

        self.buffer_image = QImage(0, 0, QImage.Format.Format_RGB32)

        # Setting up the main canvas
        self.image = QImage(self.width(), self.height(), QImage.Format.Format_RGB32)
        self.image.fill(Qt.GlobalColor.white)

        # Image stack size for Undo/Redo
        self.image_stack_limit = 50
        self.image_stack = list()
        self.image_stack.append(self.image.copy())
        self.current_stack_position = 0

        # Setting Default Tools
        self.painting = False
        self.pen_size = 3
        self.pen_color = Qt.GlobalColor.black
        self.pen_style = Qt.PenStyle.SolidLine
        self.pen_cap = Qt.PenCapStyle.RoundCap
        self.pen_join = Qt.PenJoinStyle.RoundJoin

        self.last_point = QPoint()

    def resizeEvent(self, event):

        # Save current image to buffer
        self.buffer_image = self.image

        # Adjust the canvas to the new window size and clear the canvas to avoid distortion
        self.image = self.image.scaled(self._parent.size().width(), self._parent.size().height())
        self.image.fill(Qt.GlobalColor.white)

        # Transfer the image from the buffer to the canvas, to the starting coordinate
        painter = QPainter(self.image)
        painter.drawImage(QPoint(0, 0), self.buffer_image)

    def mousePressEvent(self, event):

        if event.button() == Qt.MouseButton.LeftButton:
            painter = QPainter(self.image)
            painter.setPen(QPen(self.pen_color, self.pen_size, self.pen_style, self.pen_cap, self.pen_join))
            painter.drawPoint(event.pos())
            self.painting = True
            self.last_point = event.pos()

        self.update()

    def mouseMoveEvent(self, event):

        if (event.buttons() == Qt.MouseButton.LeftButton) and self.painting:
            painter = QPainter(self.image)
            painter.setPen(QPen(self.pen_color, self.pen_size, self.pen_style, self.pen_cap, self.pen_join))
            painter.drawLine(self.last_point, event.pos())
            self.last_point = event.pos()

        self.update()

    def mouseReleaseEvent(self, event):

        if event.button() == Qt.MouseButton.LeftButton:
            self.painting = False

            # Replacing an incorrectly sized zero (clean) image
            if len(self.image_stack) >= 1:
                temp_zero_img = self.image.copy()
                temp_zero_img.fill(Qt.GlobalColor.white)
                self.image_stack[0] = temp_zero_img.copy()


            if (len(self.image_stack) < self.image_stack_limit and
                    not (self.current_stack_position < len(self.image_stack) - 1)):

                self.image_stack.append(self.image.copy())
                self.current_stack_position = len(self.image_stack) - 1

                self.update()

            elif self.current_stack_position < len(self.image_stack) - 1:

                for i in range(len(self.image_stack) - 1, self.current_stack_position, -1):
                    self.image_stack.pop(i)

                self.image_stack.append(self.image.copy())

                self.current_stack_position = len(self.image_stack) - 1

            else:
                # Shift elements in a list
                self.image_stack.pop(0)
                # Replacing the last element (which was previously the first) with a new element
                self.image_stack.append(self.image.copy())

                self.current_stack_position = len(self.image_stack) - 1

            self.update()

    def paintEvent(self, event):

        canvas_painter = QPainter(self)

        canvas_painter.drawImage(QPoint(0, 0), self.image)

    def undo(self):

        # If the current position is not at the very minimum
        if self.current_stack_position > 0:
            self.current_stack_position -= 1

            self.image = self.image_stack[self.current_stack_position].copy()

            self.update()

    def redo(self):
        # If the current position is not at the very maximum of the stack
        if self.current_stack_position < len(self.image_stack) - 1:
            self.current_stack_position += 1

            self.image = self.image_stack[self.current_stack_position].copy()

            self.update()

    def keyPressEvent(self, event):
        print(event.key())

    def clear(self):

        # Reset current stack position
        self.current_stack_position = 0

        # Clear canvas
        self.image.fill(Qt.GlobalColor.white)

        # Copy clear canvas
        canvas = self.image.copy()

        # Clear Undo-Redo stack
        self.image_stack.clear()

        # Add zero image
        self.image_stack.append(canvas.copy())

        self.update()

The window of our artist itself:
PaintWindow.py

from PySide6.QtWidgets import QMainWindow, QWidget, QColorDialog, QSizePolicy, QLabel, QSpinBox, QPushButton
from PySide6.QtGui import QIcon, QUndoStack
from PySide6.QtCore import Qt, QSize

from PaintNote.PaintNote.PaintNote.Ui_PaintWindow import Ui_PaintWindow
from PaintNote.PaintNote.PaintNote.PaintingArea import PaintingArea

from PaintNote.PaintNote.PaintNote.res import rc_icons


class PaintWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.ui = Ui_PaintWindow()
        self.ui.setupUi(self)

        self.setWindowTitle('Paint note')
        self.setWindowIcon(QIcon(':/icons/colors.png'))

        # Save
        self.save_button = QPushButton(QIcon(':/icons/save.png'), '', self.ui.toolbar)
        self.ui.toolbar.addWidget(self.save_button)

        # Spacer 1
        self.spacer1 = QWidget()
        self.spacer1.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
        self.ui.toolbar.addWidget(self.spacer1)

        # Set pen color
        self.pen_color_button = QPushButton(QIcon(':/icons/colors.png'), '', self.ui.toolbar)
        self.ui.toolbar.addWidget(self.pen_color_button)

        # Set pen size
        self.pen_size_label = QLabel()
        self.pen_size_label.setText('Pen size:')
        self.ui.toolbar.addWidget(self.pen_size_label)

        self.pen_size_spinbox = QSpinBox()
        self.pen_size_spinbox.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
        self.pen_size_spinbox.setMinimumSize(QSize(75, 24))
        self.pen_size_spinbox.setMinimum(1)
        self.ui.toolbar.addWidget(self.pen_size_spinbox)

        # Spacer 2
        self.spacer2 = QWidget()
        self.spacer2.setMinimumWidth(40)
        self.spacer2.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
        self.ui.toolbar.addWidget(self.spacer2)

        # Undo
        self.undo_button = QPushButton(QIcon(':/icons/back.png'), '', self.ui.toolbar)
        self.ui.toolbar.addWidget(self.undo_button)

        # Redo
        self.redo_button = QPushButton(QIcon(':/icons/forward.png'), '', self.ui.toolbar)
        self.ui.toolbar.addWidget(self.redo_button)

        # Spacer 3
        self.spacer3 = QWidget()
        self.spacer3.setMinimumWidth(40)
        self.spacer3.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
        self.ui.toolbar.addWidget(self.spacer3)

        # Clear canvas
        self.clear_button = QPushButton(QIcon(':/icons/garbage.png'), '', self.ui.toolbar)
        self.ui.toolbar.addWidget(self.clear_button)

        # ----------------- Undo/Redo -----------------
        self.undoStack = QUndoStack(self)
        self.undoStack.setUndoLimit(30)

        # ======================== StatusBar settings ========================

        self.cursor_coordinates_label = QLabel()
        self.ui.statusbar.addWidget(self.cursor_coordinates_label)

        # Signal - slot

        self.save_button.clicked.connect(self.save)
        self.pen_color_button.clicked.connect(self.set_pen_color)
        self.undo_button.clicked.connect(self.undo)
        self.redo_button.clicked.connect(self.redo)
        self.pen_size_spinbox.valueChanged.connect(self.set_pen_size)
        self.clear_button.clicked.connect(self.clear_canvas)

    def save(self):
        self.ui.canvas.image.save('.\\test.png', 'PNG', -1)

    def set_pen_size(self):
        self.ui.canvas.pen_size = self.pen_size_spinbox.value()

    def set_pen_color(self):
        color_dialog = QColorDialog()
        color = color_dialog.getColor()
        if color.isValid():
            self.ui.canvas.pen_color = color

    def undo(self):
        self.ui.canvas.undo()

    def redo(self):
        self.ui.canvas.redo()

    def clear_canvas(self):
        # Clear canvas
        self.ui.canvas.clear()
  1. Painting on canvas

Drawing on canvas occurs in the module PaintingArea. The canvas is QImage.

The maximum available size is set, i.e. the window itself PaintingAreaand the background is just white.

The brush needs to be adjusted.

# Setting Default Tools
self.painting = False
self.pen_size = 3
self.pen_color = Qt.GlobalColor.black
self.pen_style = Qt.PenStyle.SolidLine
self.pen_cap = Qt.PenCapStyle.RoundCap
self.pen_join = Qt.PenJoinStyle.RoundJoin

Also enter a variable that will store the last coordinate needed for drawing.

self.last_point = QPoint()

When you press the left mouse button, a drawing event occurs.

def mousePressEvent(self, event):

    if event.button() == Qt.MouseButton.LeftButton:
        painter = QPainter(self.image)
        painter.setPen(QPen(self.pen_color, self.pen_size, self.pen_style, self.pen_cap, self.pen_join))
        painter.drawPoint(event.pos())
        self.painting = True
        self.last_point = event.pos()

    self.update()

A QPainter object is created, a brush is assigned to it, and the painting process begins. During painting, the self.painting variable is set to True, and will have this value until the left mouse button is released.

The self.last_point variable (mentioned above) stores the current position of the cursor on the canvas.

In order for the drawing to be displayed, the update() method is called, which in turn calls the paintEvent() method.

In order to “not stand” in place, i.e. to draw not only a point, but some lines, a movement is necessary. It is processed in the following method:

def mouseMoveEvent(self, event):

    if (event.buttons() == Qt.MouseButton.LeftButton) and self.painting:
        painter = QPainter(self.image)
        painter.setPen(QPen(self.pen_color, self.pen_size, self.pen_style, self.pen_cap, self.pen_join))
        painter.drawLine(self.last_point, event.pos())
        self.last_point = event.pos()

    self.update()

This is where the meaning comes in handy. True variable self.paintingwhich, together with the left mouse button held down, allows you to continue drawing continuously.

The class object is created again. QPainteras a parent it is passed a canvas, i.e. an object QImage. The brush is set again. Here, a line is drawn instead of a point. The last coordinate from the method is used as the starting point. mousePressEventand as the final coordinate – the new coordinate, i.e. where the cursor has moved. And again the last coordinate is remembered, which will be used when continuing drawing.

To stop continuous drawing (lines), simply release the left mouse button.

This will call the following event handler – mouseReleaseEvent.

In my example, most of the code is related to the Undo/Redo functionality, which will be discussed below. But here is part of the code:

def mouseReleaseEvent(self, event):

    if event.button() == Qt.MouseButton.LeftButton:
        self.painting = False

Since the left mouse button was released, the check is performed on it.

Inside a variable self.painting the value is assigned Falsewhich completes continuous drawing, which can be started again by holding down the LMB.

  1. Change the brush size.

    Changing the brush size is done using the object QSpinBox.

    Changing the brush size

    Changing the brush size

There is an object in the main window QSpinBoxwhen the value changes, a signal is triggered

self.pen_size_spinbox.valueChanged.connect(self.set_pen_size)

and is processed in the slot

def set_pen_size(self):
    self.ui.canvas.pen_size = self.pen_size_spinbox.value()

which refers to the module with the canvas.

3.Changing the brush color.

Changing the brush color, as well as changing the brush size, starts from the main window

Change brush color

Change brush color

There is an object in the main window QPushButtonwhen pressed, a signal is triggered

self.pen_color_button.clicked.connect(self.set_pen_color)

and is processed in the slot

def set_pen_color(self):
    color_dialog = QColorDialog()
    color = color_dialog.getColor()
    if color.isValid():
        self.ui.canvas.pen_color = color

In this case, a dialog box object is created with a color selection.

Color selection dialog box

Color selection dialog box

After selecting the desired color, the result is returned, and if it is correct, then an appeal is made through the canvas to the brush, which sets the selected color.

4. Changing the canvas size

It's much more interesting with changing the canvas size. I couldn't implement this feature once, but somehow I came up with two possible solutions, one of which I was able to implement and it worked.

I created a variable that will temporarily store the current canvas state before the canvas change event occurs.

self.buffer_image = QImage(0, 0, QImage.Format.Format_RGB32)

It should be noted that changing the canvas size depends on changing the window itself.

The change is processed in the module with the canvas itself

def resizeEvent(self, event):

    # Save current image to buffer
    self.buffer_image = self.image

    # Adjust the canvas to the new window size and clear the canvas to avoid distortion
    self.image = self.image.scaled(self._parent.size().width(), self._parent.size().height())
    self.image.fill(Qt.GlobalColor.white)

    # Transfer the image from the buffer to the canvas, to the starting coordinate
    painter = QPainter(self.image)
    painter.drawImage(QPoint(0, 0), self.buffer_image)

The first thing that happens is that the state of the canvas is saved, no matter whether anything is drawn or not.

Next, a new size is set for the canvas, depending on the size of the parent window.

Then, the canvas is cleared. This is necessary to avoid collisions.

And at the end we create an object QPainter with the canvas as a parent and at the zero coordinate we draw our image from the “buffer”.

I would like to point out one important point. I have moved all the controls to QToolBarso that their sizes do not affect the size of the canvas.

5. Undo/Redo function

I implemented this feature not through the Qt Undo Framework, but made my own bicycle.

Undo/Redo function

Undo/Redo function

There are two buttons in the toolbar, one for Undo and one for Redo.

They have signals.

self.undo_button.clicked.connect(self.undo)
self.redo_button.clicked.connect(self.redo)

Which begin to be processed in the same way PaintWindow

def undo(self):
    self.ui.canvas.undo()

def redo(self):
    self.ui.canvas.redo()

here in them there is already an appeal to the module with the canvas

def undo(self):

    # If the current position is not at the very minimum
    if self.current_stack_position > 0:
        self.current_stack_position -= 1

        self.image = self.image_stack[self.current_stack_position].copy()

        self.update()

def redo(self):
    # If the current position is not at the very maximum of the stack
    if self.current_stack_position < len(self.image_stack) - 1:
        self.current_stack_position += 1

        self.image = self.image_stack[self.current_stack_position].copy()

        self.update()

IN undo the current position of the top of the stack is checked. If the stack is not empty, then we decrease the stack position by one, and at this position we get the image. It is important to note that it is the copy through the method copy() and install it on the canvas. For changes to occur, you need to call update()which will cause paintEvent() for redrawing.

WITH redo the situation is similar, only a check is made to see if the current stack position is the top or not. If undo has already been used, then accordingly it can be used redo.

We increase the stack index by one and get a copy of the image from the stack at the given index, update the canvas.

As mentioned above, there will be an analysis mouseReleaseEvent.

    def mouseReleaseEvent(self, event):

        if event.button() == Qt.MouseButton.LeftButton:
            self.painting = False

            # Replacing an incorrectly sized zero (clean) image
            if len(self.image_stack) >= 1:
                temp_zero_img = self.image.copy()
                temp_zero_img.fill(Qt.GlobalColor.white)
                self.image_stack[0] = temp_zero_img.copy()

            if (len(self.image_stack) < self.image_stack_limit and
                    not (self.current_stack_position < len(self.image_stack) - 1)):

                self.image_stack.append(self.image.copy())
                self.current_stack_position = len(self.image_stack) - 1

                self.update()

            elif self.current_stack_position < len(self.image_stack) - 1:

                for i in range(len(self.image_stack) - 1, self.current_stack_position, -1):
                    self.image_stack.pop(i)

                self.image_stack.append(self.image.copy())

                self.current_stack_position = len(self.image_stack) - 1

            else:
                # Shift elements in a list
                self.image_stack.pop(0)
                # Replacing the last element (which was previously the first) with a new element
                self.image_stack.append(self.image.copy())

                self.current_stack_position = len(self.image_stack) - 1

            self.update()

First of all, at the beginning of the handler, we need to get a pure image into the stack. To avoid collision.

if len(self.image_stack) >= 1:
    temp_zero_img = self.image.copy()
    temp_zero_img.fill(Qt.GlobalColor.white)
    self.image_stack[0] = temp_zero_img.copy()

We get the first image on which at least something happened (drawing a point, line, etc.), clear it and place it in the first position of the image stack.

Then there are various situations. The thing is that there is a certain stack limit, which is set in the canvas module constructor

# Image stack size for Undo/Redo
self.image_stack_limit = 50
self.image_stack = list()
self.image_stack.append(self.image.copy())
self.current_stack_position = 0

In this case it is equal to 50 images.

if (len(self.image_stack) < self.image_stack_limit and
        not (self.current_stack_position < len(self.image_stack) - 1)):

    self.image_stack.append(self.image.copy())
    self.current_stack_position = len(self.image_stack) - 1

    self.update()

If the limit has not yet been reached and no transaction has occurred Undothen we simply add a new image to the stack.

elif self.current_stack_position < len(self.image_stack) - 1:

    for i in range(len(self.image_stack) - 1, self.current_stack_position, -1):
        self.image_stack.pop(i)

    self.image_stack.append(self.image.copy())

    self.current_stack_position = len(self.image_stack) - 1

If after use Redo there was a new drawing, then it is necessary to clear the stack image to the point where the rewind was Undo. Then insert the image and mark the new position of the top of the stack.

else:
    # Shift elements in a list
    self.image_stack.pop(0)
    self.image_stack.append(self.image.copy())

    self.current_stack_position = len(self.image_stack) - 1

If drawing occurs and the stack limit is reached, then we remove the first element of the stack and add a new image to the end, i.e. we make a shift according to the queue principle.

And don't forget to call canvas update at the end of the handler

self.update()

6. Cleaning the canvas.

Cleaning the canvas

Cleaning the canvas

The canvas can be cleared. You need to click on the button on the toolbar. The cleaning process begins in the module with the main window, we trigger the signal

self.clear_button.clicked.connect(self.clear_canvas)

and the processor

def clear_canvas(self):
    # Clear canvas
    self.ui.canvas.clear()

Which already calls a method in the canvas module

def clear(self):

    # Reset current stack position
    self.current_stack_position = 0

    # Clear canvas
    self.image.fill(Qt.GlobalColor.white)

    # Copy clear canvas
    canvas = self.image.copy()

    # Clear Undo-Redo stack
    self.image_stack.clear()

    # Add zero image
    self.image_stack.append(canvas.copy())

    self.update()

First we reset the position of the top of the stack pointer.

Next, we clear the image and copy it to paste it onto the canvas (keeping the last size). Finally, we clear the stack and call canvas refresh.

7.Saving the image.

Saving an image

Saving an image

Our image can be saved. For simplicity of the example, I save it in the same directory where the project is located.

It also starts with pressing a button on the toolbar and triggering a signal

self.save_button.clicked.connect(self.save)

and the processor

def save(self):
    self.ui.canvas.image.save('.\\test.png', 'PNG', -1)

which refers to the canvas and its method save(). We specify the path and name of the file, extension and quality.

Summary.

Thus, it is possible to implement a simple drawing program. True, so far I have not managed to implement the element “select and cut”, but I hope my example and my article can help someone.

Link to the project

Thank you for your attention.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *