Basic Paint Application
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
- 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
The save-as-image feature uses Pillow (the fork of the classic PIL):
pip install pillowpip install pillowTkinter itself ships with Python — no install.
Getting Started
Create the project
- Create a folder named
paint-apppaint-app. - Inside it, create
paint.pypaint.py.
Write the code
Paint Application
Source# 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()
# 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
python paint.pypython 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.
Step-by-Step Explanation
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()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 selfself without sprinkling globals throughout the code.
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)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)FrameFrame/LabelFrameLabelFramegroups related widgets.- The
command=lambda c=color: ...command=lambda c=color: ...trick is essential: without the default-argument capture, all buttons would end up using the last color in the loop. ScaleScaleis a slider; we use it for brush size.CanvasCanvasis the drawing surface.
3. Bind mouse events
def bind_events(self):
self.canvas.bind("<B1-Motion>", self.paint)
self.canvas.bind("<ButtonRelease-1>", self.reset)def bind_events(self):
self.canvas.bind("<B1-Motion>", self.paint)
self.canvas.bind("<ButtonRelease-1>", self.reset)<B1-Motion><B1-Motion>fires repeatedly while the left mouse button is held and the cursor moves.<ButtonRelease-1><ButtonRelease-1>fires once when the button is released.
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)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_linecreate_linebetween the previous and current mouse positions. Track the last point inself.old_x, self.old_yself.old_x, self.old_yand reset them inreset()reset().
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 = colordef 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
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)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")delete("all")removes every drawn item.- Saving uses
PIL.ImageGrab.grabPIL.ImageGrab.grabto screen-capture the canvas region.winfo_rootx/ywinfo_rootx/ygive the canvas position in screen coordinates (not window coordinates).
- A more advanced approach maintains a parallel
PIL.ImagePIL.Imageand draws into it viaPIL.ImageDrawPIL.ImageDraw— that way the saved image is identical regardless of what is on screen (no risk of capturing an overlapping window).
Common Mistakes
| Problem | Cause | Fix |
|---|---|---|
| All color buttons paint the same color | Closure captured the loop variable | Use lambda c=color: ...lambda c=color: ... to bind early |
| Brush leaves dotted line | Events fire faster than they can be drawn | Use create_linecreate_line between consecutive points |
ImageGrab.grab()ImageGrab.grab() returns the wrong region | Used canvas-local coordinates | Use winfo_rootx/ywinfo_rootx/y for screen coordinates |
| Eraser leaves black on a colored canvas | Eraser color hard-coded to white | Update self.eraser_colorself.eraser_color whenever the canvas color changes |
| Save dialog hangs | No file extension | Use asksaveasfilename(defaultextension=".jpg")asksaveasfilename(defaultextension=".jpg") |
Variations to Try
1. Custom color picker
Replace the eight preset buttons with a single “Choose color…” button using colorchooser.askcolorcolorchooser.askcolor.
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 = Nonedef 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
Maintain a stack of canvas-item IDs. On undo, canvas.delete(last_id)canvas.delete(last_id).
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
Click on the canvas → open an EntryEntry → press Enter → place the text there.
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
Use multiple CanvasCanvas widgets stacked together, or maintain an in-memory PIL.ImagePIL.Image per layer and composite on save.
8. Image import
Use PIL.Image.openPIL.Image.open → ImageTk.PhotoImageImageTk.PhotoImage → canvas.create_imagecanvas.create_image to bring an existing picture onto the canvas, then paint over it.
9. Touch support
On a tablet or touch-screen Tkinter receives events the same way. Try it.
10. Save as PNG with transparency
Drop the screen-grab approach and use a parallel PIL.ImagePIL.Image so the saved PNG truly matches what is drawn — no background bleed.
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
This project teaches:
- Event-driven GUI — bindings, callbacks, the main loop.
- Canvas programming — creating, deleting, and managing graphic items.
- Layout managers —
packpack,gridgrid, frames, side-by-side regions. - State management — what color, what tool, what size, all stored on
selfself. - Cross-library bridges — Tkinter + Pillow to produce a real file.
- Class-based design — keeping UI code organized.
Next Steps
- Add smooth-brush drawing using
create_linecreate_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
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
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.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
