Skip to content

Basic Paint Application

A paint program is the perfect mid-level project because it stretches every part of GUI programming: drawing on a canvas, tracking mouse motion, handling many simultaneous widgets, and saving the result to a file. In this project you will build a Tkinter-based paint application with a color palette, an adjustable brush, an eraser, a canvas-color picker, a clear button, and a save-to-JPEG feature.

Along the way you will learn:

  • How to capture continuous mouse-drag events with Tkinter bindings.
  • Why drawing a stream of small ovals looks like a smooth brush stroke.
  • How to lay out widgets with frames, grids, and packs.
  • How to capture the canvas as an image via screen grab.
  • How to organize a non-trivial GUI program with a class.
  • Python 3.6 or above.
  • A text editor or IDE.
  • Comfort with classes (or a willingness to learn — we use them here).
  • Familiarity with Tkinter basics. If you have built the Basic Music Player you already know enough.

The save-as-image feature uses Pillow (the fork of the classic PIL):

install
pip install pillow

Tkinter itself ships with Python — no install.

  1. Create a folder named paint-app.
  2. Inside it, create paint.py.
Paint Application pch.viewSource
Paint Application
# Basic Paint Application in Python

from tkinter import *
from tkinter import ttk
from tkinter import colorchooser
from tkinter import messagebox
from tkinter import filedialog
import PIL
from PIL import ImageGrab
import os

class Paint:
    def __init__(self, root):
        self.root = root
        self.root.title("Paint")
        self.root.geometry("800x520")
        self.root.configure(background="white")
        self.root.resizable(0,0)
        self.root.bind("<B1-Motion>", self.paint)
        self.root.bind("<ButtonRelease-1>", self.reset)
        
        self.pen_color = "black"
        self.eraser_color = "white"
        
        self.color_frame = LabelFrame(self.root, text="Color", font=("arial", 15), bd=5, relief=RIDGE, bg="white")
        self.color_frame.place(x=0, y=0, width=70, height=185)
        
        colors = ["#ff0000", "#ff4000", "#ff8000", "#ffbf00", "#ffff00", "#bfff00", "#80ff00", "#40ff00", "#00ff00", "#00ff40", "#00ff80", "#00ffbf", "#00ffff", "#00bfff", "#0080ff", "#0040ff", "#0000ff", "#4000ff", "#8000ff", "#bf00ff", "#ff00ff", "#ff00bf", "#ff0080", "#ff0040", "#ff0000", "#000000", "#ffffff"]
        
        i=j=0
        for color in colors:
            Button(self.color_frame, bg=color, bd=2, relief=RIDGE, width=3, command=lambda col=color:self.select_color(col)).grid(row=i, column=j)
            i+=1
            if i==23:
                i=0
                j=1
                
        self.eraser_button = Button(self.root, text="Eraser", bd=4, bg="white", command=self.eraser, width=8, relief=RIDGE)
        self.eraser_button.place(x=0, y=187)
        
        self.clear_button = Button(self.root, text="Clear", bd=4, bg="white", command=lambda :self.canvas.delete("all"), width=8, relief=RIDGE)
        self.clear_button.place(x=0, y=217)
        
        self.save_button = Button(self.root, text="Save", bd=4, bg="white", command=self.save_paint, width=8, relief=RIDGE)
        self.save_button.place(x=0, y=247)
        
        self.canvas_color_button = Button(self.root, text="Canvas", bd=4, bg="white", command=self.canvas_color, width=8, relief=RIDGE)
        
        self.canvas_color_button.place(x=0, y=277)
        
        self.canvas_width = Scale(self.root, from_=1, to=100, orient=VERTICAL)
        self.canvas_width.set(1)
        
        self.canvas_width.place(x=0, y=330)
        
        self.canvas = Canvas(self.root, bg="white", bd=5, relief=GROOVE, height=500, width=700)
        self.canvas.place(x=80, y=0)
        
    def paint(self, event):
        x1, y1 = (event.x-2), (event.y-2)
        x2, y2 = (event.x+2), (event.y+2)
        
        self.canvas.create_oval(x1, y1, x2, y2, fill=self.pen_color, outline=self.pen_color, width=self.canvas_width.get())
        
    def select_color(self, col):
        self.pen_color = col
        
    def eraser(self):
        self.pen_color = self.eraser_color
        
    def reset(self, event):
        self.old_x = None
        self.old_y = None
        
    def canvas_color(self):
        color = colorchooser.askcolor(title="Select Color")
        self.canvas.configure(background=color[1])
        self.eraser_color = color[1]
        
    def save_paint(self):
        try:
            filename = filedialog.asksaveasfilename(defaultextension=".jpg")
            x = self.root.winfo_rootx() + self.canvas.winfo_x()
            y = self.root.winfo_rooty() + self.canvas.winfo_y()
            x1 = x + self.canvas.winfo_width()
            y1 = y + self.canvas.winfo_height()
            PIL.ImageGrab.grab().crop((x,y,x1,y1)).save(filename)
            messagebox.showinfo("Paint says", "Image is saved as " + str(os.path.basename(filename)) + " successfully")
        except:
            messagebox.showerror("Paint says", "Unable to save image")
            
if __name__ == "__main__":
    root = Tk()
    p = Paint(root)
    root.mainloop()
run
python paint.py

A window appears with a color palette on the left, a brush-size slider on the right, a clear button at the top, and a large white canvas in the middle. Click and drag to draw.

Read from the top: this is what runs when you execute the file, and which function calls which. It is generated from the code, so it cannot drift from it.

diagram Diagram mermaid
paint.py
class Paint:
    def __init__(self, root):
        self.root = root
        self.root.title("Paint")
        self.root.geometry("800x520")
        self.pen_color = "black"
        self.eraser_color = "white"
        self.build_ui()
        self.bind_events()

A class lets us stash state (pen color, brush size, canvas reference) on self without sprinkling globals throughout the code.

paint.py
def build_ui(self):
    self.color_frame = LabelFrame(self.root, text="Colors", padx=5, pady=5)
    self.color_frame.pack(side=LEFT, fill=Y)
 
    colors = ["#000000", "#ff0000", "#ff8000", "#ffff00",
              "#00ff00", "#0080ff", "#0000ff", "#8000ff"]
    for i, color in enumerate(colors):
        Button(self.color_frame, bg=color, width=3, bd=2,
               command=lambda c=color: self.set_pen_color(c)).grid(row=i//2, column=i%2)
 
    self.canvas_width = Scale(self.root, from_=1, to=100, orient=VERTICAL, label="Size")
    self.canvas_width.set(5)
    self.canvas_width.pack(side=RIGHT, fill=Y)
 
    self.canvas = Canvas(self.root, bg="white")
    self.canvas.pack(fill=BOTH, expand=True)
  • Frame / LabelFrame groups related widgets.
  • The command=lambda c=color: ... trick is essential: without the default-argument capture, all buttons would end up using the last color in the loop.
  • Scale is a slider; we use it for brush size.
  • Canvas is the drawing surface.
paint.py
def bind_events(self):
    self.canvas.bind("<B1-Motion>", self.paint)
    self.canvas.bind("<ButtonRelease-1>", self.reset)
  • <B1-Motion> fires repeatedly while the left mouse button is held and the cursor moves.
  • <ButtonRelease-1> fires once when the button is released.
paint.py
def paint(self, event):
    size = self.canvas_width.get()
    x1, y1 = event.x - size, event.y - size
    x2, y2 = event.x + size, event.y + size
    self.canvas.create_oval(x1, y1, x2, y2,
                            fill=self.pen_color,
                            outline=self.pen_color)

Every mouse-move event draws a small filled oval at the cursor. Because the events fire many times per second, the result looks like a continuous brush stroke. With a bigger brush size, the ovals are larger and the stroke gets wider.

💡 For a truly continuous line, use create_line between the previous and current mouse positions. Track the last point in self.old_x, self.old_y and reset them in reset().

paint.py
def set_pen_color(self, color):
    self.pen_color = color
 
def use_eraser(self):
    self.pen_color = self.eraser_color    # erase = paint with bg color
 
def pick_canvas_color(self):
    color = colorchooser.askcolor(title="Canvas color")[1]
    if color:
        self.canvas.configure(background=color)
        self.eraser_color = color

An eraser is just paint that matches the background. Picking a new canvas color updates the eraser to match.

paint.py
def clear_canvas(self):
    self.canvas.delete("all")
 
def save_canvas(self):
    filename = filedialog.asksaveasfilename(defaultextension=".jpg")
    if not filename: return
    self.root.update()                     # ensure window is fully drawn
    x = self.root.winfo_rootx() + self.canvas.winfo_x()
    y = self.root.winfo_rooty() + self.canvas.winfo_y()
    x1 = x + self.canvas.winfo_width()
    y1 = y + self.canvas.winfo_height()
    ImageGrab.grab(bbox=(x, y, x1, y1)).save(filename)
  • delete("all") removes every drawn item.
  • Saving uses PIL.ImageGrab.grab to screen-capture the canvas region.
    • winfo_rootx/y give the canvas position in screen coordinates (not window coordinates).
  • A more advanced approach maintains a parallel PIL.Image and draws into it via PIL.ImageDraw — that way the saved image is identical regardless of what is on screen (no risk of capturing an overlapping window).
ProblemCauseFix
All color buttons paint the same colorClosure captured the loop variableUse lambda c=color: ... to bind early
Brush leaves dotted lineEvents fire faster than they can be drawnUse create_line between consecutive points
ImageGrab.grab() returns the wrong regionUsed canvas-local coordinatesUse winfo_rootx/y for screen coordinates
Eraser leaves black on a colored canvasEraser color hard-coded to whiteUpdate self.eraser_color whenever the canvas color changes
Save dialog hangsNo file extensionUse asksaveasfilename(defaultextension=".jpg")

Replace the eight preset buttons with a single “Choose color…” button using colorchooser.askcolor.

smooth.py
def paint(self, event):
    if self.old_x and self.old_y:
        self.canvas.create_line(self.old_x, self.old_y,
                                event.x, event.y,
                                width=self.canvas_width.get(),
                                fill=self.pen_color,
                                capstyle=ROUND, smooth=True)
    self.old_x, self.old_y = event.x, event.y
 
def reset(self, event):
    self.old_x = self.old_y = None

A real, smooth brush stroke.

Maintain a stack of canvas-item IDs. On undo, canvas.delete(last_id).

A toolbar with rectangle, oval, and line tools. On mouse-press record the start point; on release create the shape.

Click on the canvas → open an Entry → press Enter → place the text there.

Each mouse event creates a random pattern of small dots inside a radius. Looks more like spray than a brush.

Use multiple Canvas widgets stacked together, or maintain an in-memory PIL.Image per layer and composite on save.

Use PIL.Image.openImageTk.PhotoImagecanvas.create_image to bring an existing picture onto the canvas, then paint over it.

On a tablet or touch-screen Tkinter receives events the same way. Try it.

Drop the screen-grab approach and use a parallel PIL.Image so the saved PNG truly matches what is drawn — no background bleed.

  • Image annotation tools for ML labeling.
  • Whiteboard apps for remote teaching.
  • Quick mockup tools for designers.
  • Signature capture in form-filling apps.
  • Drawing input for ML models that take strokes (handwriting recognition, sketch classification).

This project teaches:

  • Event-driven GUI — bindings, callbacks, the main loop.
  • Canvas programming — creating, deleting, and managing graphic items.
  • Layout managerspack, grid, frames, side-by-side regions.
  • State management — what color, what tool, what size, all stored on self.
  • Cross-library bridges — Tkinter + Pillow to produce a real file.
  • Class-based design — keeping UI code organized.
  • Add smooth-brush drawing using create_line as shown above.
  • Implement undo/redo with a stack of item IDs.
  • Add a toolbar with rectangle, oval, line, and text tools.
  • Replace the screen-grab save with a PIL-backed image for true layered drawing.
  • Port the UI to CustomTkinter for a modern look.
  • Convert it into a collaborative whiteboard with WebSockets — pair the GUI with a Flask backend.

Here’s the same idea running in your browser — click and drag to draw, click a colour swatch to switch brushes, and press C to clear:

sketch A mini paint canvas p5.js
Click and drag to draw. Click a swatch to change colour; press C to clear the canvas.

You built a real desktop paint app: a canvas, a palette, brush controls, save-as-image, and a class-based architecture you can keep extending. Tkinter shows its age in styling but its power model — widgets, events, the main loop — is identical to what big frameworks use. Full source on GitHub. Find more GUI projects on Python Central Hub.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading