import tkinter as tk
from tkinter import filedialog, colorchooser
from PIL import Image, ImageTk
class PixelEditor:
def __init__(self, root):
self.root = root
self.root.title("Pro Pixel Editor")
# Configuration
self.grid_size = 16
self.scale = 25
self.canvas_res = self.grid_size * self.scale
self.image = Image.new("RGBA", (self.grid_size, self.grid_size), (0,0,0,0))
self.history = []
self.future = []
self.max_history = 50 # Limit memory usage
self.color = (0, 0, 0, 255)
self.tool = "pen"
self.dark = True
self.show_grid = True
self.last_pos = None
# UI Setup
self.setup_ui()
# Canvas
self.canvas = tk.Canvas(root, width=self.canvas_res, height=self.canvas_res, highlightthickness=0)
self.canvas.pack(pady=10, padx=10)
# Binds
self.canvas.bind("<B1-Motion>", self.paint)
self.canvas.bind("<Button-1>", self.start_paint)
self.canvas.bind("<ButtonRelease-1>", self.reset_drag)
self.canvas.bind("<Button-3>", self.pick_from_canvas)
# Keyboard Shortcuts
self.root.bind("<Control-z>", lambda e: self.undo())
self.root.bind("<Control-y>", lambda e: self.redo())
self.root.bind("<Control-s>", lambda e: self.save())
self.apply_theme()
self.draw()
def setup_ui(self):
self.toolbar = tk.Frame(self.root)
self.toolbar.pack(fill="x", padx=5, pady=5)
self.status = tk.Label(self.root, text="Tool: PEN", font=("Arial", 10, "bold"))
self.status.pack(fill="x")
# Create buttons with a loop for cleaner code
btns = [
("Load", self.load),
("Save", self.save),
("Color", self.pick_color),
("Pen", lambda: self.set_tool("pen")),
("Eraser", lambda: self.set_tool("eraser")),
("Undo (Ctrl+Z)", self.undo),
("Redo (Ctrl+Y)", self.redo),
("Grid", self.toggle_grid),
("Theme", self.toggle_theme),
]
self.btn_objs = {}
for text, cmd in btns:
b = tk.Button(self.toolbar, text=text, command=cmd, relief="flat", padx=10)
b.pack(side="left", padx=2)
self.btn_objs[text] = b
self.update_tool_ui()
def apply_theme(self):
bg = "#1e1e1e" if self.dark else "#eeeeee"
fg = "#ffffff" if self.dark else "#000000"
btn_bg = "#333333" if self.dark else "#dddddd"
self.root.configure(bg=bg)
self.toolbar.configure(bg=bg)
self.status.configure(bg=bg, fg=fg)
for b in self.btn_objs.values():
b.configure(bg=btn_bg, fg=fg, activebackground="#555555")
self.canvas.configure(bg="#2b2b2b" if self.dark else "#ffffff")
def toggle_theme(self):
self.dark = not self.dark
self.apply_theme()
def toggle_grid(self):
self.show_grid = not self.show_grid
self.draw()
def set_tool(self, tool):
self.tool = tool
self.update_tool_ui()
def update_tool_ui(self):
self.status.config(text=f"Tool: {self.tool.upper()}")
# Highlight active tool
for name, obj in self.btn_objs.items():
if name.lower() == self.tool:
obj.configure(relief="sunken", bg="#555555" if self.dark else "#bbb")
elif name.lower() in ["pen", "eraser"]:
obj.configure(relief="flat", bg="#333333" if self.dark else "#ddd")
def pick_color(self):
c = colorchooser.askcolor()[0]
if c:
self.color = (int(c[0]), int(c[1]), int(c[2]), 255)
def pick_from_canvas(self, event):
x, y = event.x // self.scale, event.y // self.scale
if 0 <= x < self.grid_size and 0 <= y < self.grid_size:
self.color = self.image.getpixel((x, y))
# --- UNDO / REDO LOGIC UPGRADED ---
def save_to_history(self):
"""Saves current state before a new stroke starts."""
# Add current image to history
self.history.append(self.image.copy())
# Prevent history from getting too huge
if len(self.history) > self.max_history:
self.history.pop(0)
# Clear redo stack whenever a new action is performed
self.future.clear()
def undo(self):
if self.history:
self.future.append(self.image.copy())
self.image = self.history.pop()
self.draw()
def redo(self):
if self.future:
self.history.append(self.image.copy())
self.image = self.future.pop()
self.draw()
# --- PAINTING LOGIC ---
def start_paint(self, event):
# Save state ONCE at the start of the click
self.save_to_history()
self.last_pos = (event.x // self.scale, event.y // self.scale)
self.paint(event)
def reset_drag(self, event):
self.last_pos = None
def paint(self, event):
x = event.x // self.scale
y = event.y // self.scale
if not (0 <= x < self.grid_size and 0 <= y < self.grid_size):
return
if self.last_pos:
# draw_line updates the self.image pixels
self.draw_line(self.last_pos, (x, y))
else:
self.apply_pixel(x, y)
self.last_pos = (x, y)
self.draw()
def draw_line(self, p1, p2):
"""Bresenham's Line Algorithm to ensure no gaps when mouse moves fast."""
x1, y1 = p1
x2, y2 = p2
dx, dy = abs(x2 - x1), abs(y2 - y1)
sx = 1 if x1 < x2 else -1
sy = 1 if y1 < y2 else -1
err = dx - dy
while True:
self.apply_pixel(x1, y1)
if x1 == x2 and y1 == y2: break
e2 = 2 * err
if e2 > -dy: err -= dy; x1 += sx
if e2 < dx: err += dx; y1 += sy
def apply_pixel(self, x, y):
color = self.color if self.tool == "pen" else (0,0,0,0)
self.image.putpixel((x, y), color)
def draw(self):
"""Refreshes the canvas display."""
# Resize image for preview
preview = self.image.resize((self.canvas_res, self.canvas_res), Image.NEAREST)
self.tk_img = ImageTk.PhotoImage(preview)
self.canvas.delete("all")
self.canvas.create_image(0, 0, anchor="nw", image=self.tk_img)
if self.show_grid:
color = "#444444" if self.dark else "#cccccc"
for i in range(self.grid_size + 1):
p = i * self.scale
self.canvas.create_line(p, 0, p, self.canvas_res, fill=color)
self.canvas.create_line(0, p, self.canvas_res, p, fill=color)
def load(self):
path = filedialog.askopenfilename(filetypes=[("PNG","*.png")])
if path:
self.save_to_history()
self.image = Image.open(path).convert("RGBA").resize((self.grid_size, self.grid_size), Image.NEAREST)
self.draw()
def save(self):
path = filedialog.asksaveasfilename(defaultextension=".png")
if path:
self.image.save(path, "PNG")
if __name__ == "__main__":
root = tk.Tk()
app = PixelEditor(root)
root.mainloop()
