Basic Paint Application
Abstract
Section titled “Abstract”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.
Prerequisites
Section titled “Prerequisites”- 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.
Install Pillow
Section titled “Install Pillow”The save-as-image feature uses Pillow (the fork of the classic PIL):
pip install pillowTkinter itself ships with Python — no install.
Getting Started
Section titled “Getting Started”Create the project
Section titled “Create the project”- Create a folder named
paint-app. - Inside it, create
paint.py.
Write the code
Section titled “Write the code”Paint Application
pch.viewSource# 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 it
Section titled “Run it”python paint.pyA 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.
How it fits together
Section titled “How it fits together”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.
flowchart TD RUN(["python paint.py"]) Paint["Paint
class"] RUN --> Paint
Step-by-Step Explanation
Section titled “Step-by-Step Explanation”1. Class skeleton
Section titled “1. Class skeleton”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.
2. Build the layout
Section titled “2. Build the layout”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/LabelFramegroups 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. Scaleis a slider; we use it for brush size.Canvasis the drawing surface.
3. Bind mouse events
Section titled “3. Bind mouse events”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.
4. Paint with overlapping ovals
Section titled “4. Paint with overlapping ovals”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_linebetween the previous and current mouse positions. Track the last point inself.old_x, self.old_yand reset them inreset().
5. Color and tool switching
Section titled “5. Color and tool switching”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 = colorAn eraser is just paint that matches the background. Picking a new canvas color updates the eraser to match.
6. Clear and save
Section titled “6. Clear and save”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.grabto screen-capture the canvas region.winfo_rootx/ygive the canvas position in screen coordinates (not window coordinates).
- A more advanced approach maintains a parallel
PIL.Imageand draws into it viaPIL.ImageDraw— that way the saved image is identical regardless of what is on screen (no risk of capturing an overlapping window).
Common Mistakes
Section titled “Common Mistakes”| Problem | Cause | Fix |
|---|---|---|
| All color buttons paint the same color | Closure captured the loop variable | Use lambda c=color: ... to bind early |
| Brush leaves dotted line | Events fire faster than they can be drawn | Use create_line between consecutive points |
ImageGrab.grab() returns the wrong region | Used canvas-local coordinates | Use winfo_rootx/y for screen coordinates |
| Eraser leaves black on a colored canvas | Eraser color hard-coded to white | Update self.eraser_color whenever the canvas color changes |
| Save dialog hangs | No file extension | Use asksaveasfilename(defaultextension=".jpg") |
Variations to Try
Section titled “Variations to Try”1. Custom color picker
Section titled “1. Custom color picker”Replace the eight preset buttons with a single “Choose color…” button using colorchooser.askcolor.
2. Smooth brush lines
Section titled “2. Smooth brush lines”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 = NoneA real, smooth brush stroke.
3. Undo / redo
Section titled “3. Undo / redo”Maintain a stack of canvas-item IDs. On undo, canvas.delete(last_id).
4. Shape tools
Section titled “4. Shape tools”A toolbar with rectangle, oval, and line tools. On mouse-press record the start point; on release create the shape.
5. Text tool
Section titled “5. Text tool”Click on the canvas → open an Entry → press Enter → place the text there.
6. Spray paint
Section titled “6. Spray paint”Each mouse event creates a random pattern of small dots inside a radius. Looks more like spray than a brush.
7. Layers
Section titled “7. Layers”Use multiple Canvas widgets stacked together, or maintain an in-memory PIL.Image per layer and composite on save.
8. Image import
Section titled “8. Image import”Use PIL.Image.open → ImageTk.PhotoImage → canvas.create_image to bring an existing picture onto the canvas, then paint over it.
9. Touch support
Section titled “9. Touch support”On a tablet or touch-screen Tkinter receives events the same way. Try it.
10. Save as PNG with transparency
Section titled “10. Save as PNG with transparency”Drop the screen-grab approach and use a parallel PIL.Image so the saved PNG truly matches what is drawn — no background bleed.
Real-World Applications
Section titled “Real-World Applications”- 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).
Educational Value
Section titled “Educational Value”This project teaches:
- Event-driven GUI — bindings, callbacks, the main loop.
- Canvas programming — creating, deleting, and managing graphic items.
- Layout managers —
pack,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.
Next Steps
Section titled “Next Steps”- Add smooth-brush drawing using
create_lineas 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.
Try it here
Section titled “Try it here”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:
Conclusion
Section titled “Conclusion”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.coffeeCtapch.feedbackHeading
pch.feedbackSubheading