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()
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.
Change the brush size.
Changing the brush size is done using the object QSpinBox.
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
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.
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.
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.
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.
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.
Thank you for your attention.