adding brush functionality and a new canvas for the brush settings

This commit is contained in:
vb
2025-11-05 17:09:49 +01:00
parent 27cdbaa071
commit f34bf706fb
2 changed files with 252 additions and 13 deletions

View File

@@ -1,6 +1,6 @@
import tkinter as tk
from tkinter import filedialog, messagebox
from PIL import ImageTk
from tkinter import filedialog, messagebox, colorchooser, simpledialog
from PIL import ImageTk, ImageDraw
from ImageContainer import ImageContainer
from ImageManipulation.ManipulationList import *
from SelectionArea import SelectionArea
@@ -27,6 +27,17 @@ class GUI:
_selectionEndY = None
_selectionRectangle = None
_selectionArea = None
# Brush variables
_brushMode = False
_brushColor = "#000000" # Default black
_brushSize = 5 # Default brush size
_lastBrushX = None
_lastBrushY = None
_isDrawing = False
_snapshotTaken = False # Track if snapshot was taken for current stroke
_canvasImageWidth = None # Store canvas image dimensions
_canvasImageHeight = None
def __new__(cls):
if cls._instance is None:
@@ -44,11 +55,11 @@ class GUI:
# Main window
root = tk.Tk()
root.title("Image Viewer")
root.geometry("800x600")
root.geometry("1100x700")
root.config(bg="white")
icon = tk.PhotoImage(file='icon.png')
root.tk.call('wm', 'iconphoto', root._w, icon)
root.minsize(800, 600)
root.minsize(1100, 600)
# Menus
menu = tk.Menu(root)
@@ -83,14 +94,20 @@ class GUI:
edit_menu.add_separator()
edit_menu.add_command(label="Toggle Selection Mode", accelerator="Ctrl+S", command=lambda: self._toggleSelectionMode())
edit_menu.add_command(label="Toggle Selection Shape (Rect/Circle)", accelerator="Ctrl+Shift+C", command=lambda: self._toggleSelectionShape())
edit_menu.add_separator()
edit_menu.add_command(label="Brush Tool", accelerator="Ctrl+B", command=lambda: self._toggleBrushMode())
# Main container frame for image and settings
main_container = tk.Frame(root, bg="white")
main_container.pack(side="top", fill="both", expand=True, padx=10, pady=10)
# Frame to hold image
imgframe = tk.Frame(root, width=500, height=500, bg="lightgray", relief="sunken", bd=2)
imgframe.pack(side="top", pady=10)
imgframe = tk.Frame(main_container, width=500, height=500, bg="lightgray", relief="sunken", bd=2)
imgframe.pack(side="left", fill="both", expand=True, padx=(0, 10))
# Canvas to display image and draw selection
self._imageCanvas = tk.Canvas(imgframe, width=500, height=500, bg="white")
self._imageCanvas.pack(expand=True)
self._imageCanvas.pack(expand=True, fill="both")
# Label to hold the image (will be placed on canvas)
self._imageLabel = tk.Label(self._imageCanvas, bg="white")
@@ -101,6 +118,66 @@ class GUI:
self._imageCanvas.bind("<ButtonRelease-1>", self._onMouseRelease)
self._imageCanvas.bind("<Button-3>", self._onRightClick) # Right-click for context menu
# Settings panel for brush controls
settings_panel = tk.Frame(main_container, width=250, bg="lightgray", relief="sunken", bd=2)
settings_panel.pack(side="right", fill="y", padx=(10, 0))
settings_panel.pack_propagate(False) # Prevent frame from shrinking
# Brush settings title
tk.Label(settings_panel, text="Brush Settings", font=("Arial", 12, "bold"), bg="lightgray").pack(pady=15)
# Color selection section
color_section = tk.Frame(settings_panel, bg="lightgray")
color_section.pack(pady=10, padx=15, fill="x")
tk.Label(color_section, text="Brush Color:", font=("Arial", 10), bg="lightgray").pack(anchor="w")
color_frame = tk.Frame(color_section, bg="lightgray")
color_frame.pack(pady=5)
# Color preview
self._colorPreview = tk.Label(color_frame, bg=self._brushColor, width=15, height=3, relief="sunken", bd=2)
self._colorPreview.pack(side="left", padx=5)
def choose_color():
color = colorchooser.askcolor(title="Choose Brush Color", color=self._brushColor)
if color[1]: # color[1] is the hex string
self._brushColor = color[1]
self._colorPreview.config(bg=self._brushColor)
tk.Button(color_frame, text="Choose Color", command=choose_color, width=12).pack(side="left", padx=5)
# Size selection section
size_section = tk.Frame(settings_panel, bg="lightgray")
size_section.pack(pady=10, padx=15, fill="x")
tk.Label(size_section, text="Brush Size:", font=("Arial", 10), bg="lightgray").pack(anchor="w")
size_frame = tk.Frame(size_section, bg="lightgray")
size_frame.pack(pady=5, fill="x")
self._sizeVar = tk.IntVar(value=self._brushSize)
self._sizeScale = tk.Scale(size_frame, from_=1, to=50, orient="horizontal",
variable=self._sizeVar, length=200, bg="lightgray")
self._sizeScale.pack(side="left", padx=5)
self._sizeLabel = tk.Label(size_frame, textvariable=self._sizeVar, width=3, bg="lightgray")
self._sizeLabel.pack(side="left", padx=5)
def update_size(value):
self._brushSize = self._sizeVar.get()
self._sizeLabel.config(text=str(self._brushSize))
self._sizeScale.config(command=update_size)
# Current brush info
info_frame = tk.Frame(settings_panel, bg="lightgray")
info_frame.pack(pady=20, padx=15, fill="x")
tk.Label(info_frame, text="Brush Status:", font=("Arial", 10, "bold"), bg="lightgray").pack(anchor="w")
self._brushStatusLabel = tk.Label(info_frame, text="Inactive", fg="red", bg="lightgray", font=("Arial", 9))
self._brushStatusLabel.pack(anchor="w", pady=5)
self._root = root
# Initialize selection area
@@ -111,6 +188,7 @@ class GUI:
root.bind_all('<Control-y>', lambda event: self._redo())
root.bind_all('<Control-s>', lambda event: self._toggleSelectionMode())
root.bind_all('<Control-Shift-C>', lambda event: self._toggleSelectionShape())
root.bind_all('<Control-b>', lambda event: self._toggleBrushMode())
root.mainloop()
@@ -139,6 +217,10 @@ class GUI:
# Keep reference to avoid garbage collection
self._imageLabel.image = tk_img
# Store canvas image dimensions for coordinate conversion
self._canvasImageWidth = width
self._canvasImageHeight = height
# Place image on canvas
self._imageCanvas.create_image(0, 0, anchor="nw", image=tk_img)
@@ -202,7 +284,24 @@ class GUI:
self._renderCurrentImage()
def _onMouseClick(self, event) -> None:
"""Handle mouse click for selection."""
"""Handle mouse click for selection or brush."""
if self._brushMode and self._currentImage is None:
return
# Handle brush mode
if self._brushMode:
self._isDrawing = True
self._lastBrushX = event.x
self._lastBrushY = event.y
# Take snapshot for undo (only once per stroke)
if not self._snapshotTaken:
self._currentImage.snapshot()
self._snapshotTaken = True
# Draw first point
self._drawBrushPoint(event.x, event.y)
return
# Handle selection mode
if not self._selectionMode or self._currentImage is None:
return
@@ -214,7 +313,15 @@ class GUI:
self._selectionStartY = event.y
def _onMouseDrag(self, event) -> None:
"""Handle mouse drag for selection."""
"""Handle mouse drag for selection or brush."""
# Handle brush mode
if self._brushMode and self._isDrawing:
self._drawBrushLine(self._lastBrushX, self._lastBrushY, event.x, event.y)
self._lastBrushX = event.x
self._lastBrushY = event.y
return
# Handle selection mode
if not self._selectionMode or self._currentImage is None or self._selectionStartX is None:
return
@@ -235,7 +342,16 @@ class GUI:
)
def _onMouseRelease(self, event) -> None:
"""Handle mouse release to finalize selection."""
"""Handle mouse release to finalize selection or stop brush."""
# Handle brush mode
if self._brushMode and self._isDrawing:
self._isDrawing = False
self._lastBrushX = None
self._lastBrushY = None
self._snapshotTaken = False # Reset for next stroke
return
# Handle selection mode
if not self._selectionMode or self._currentImage is None or self._selectionStartX is None:
return
@@ -443,6 +559,132 @@ class GUI:
outline="red", width=3, fill=""
)
def _toggleBrushMode(self) -> None:
"""Toggle brush mode on/off."""
self._brushMode = not self._brushMode
if self._brushMode:
# Disable selection mode when brush mode is enabled
self._selectionMode = False
self._root.config(cursor="pencil")
# Update status label
if hasattr(self, '_brushStatusLabel'):
self._brushStatusLabel.config(text="Active", fg="green")
else:
self._root.config(cursor="")
self._isDrawing = False
self._lastBrushX = None
self._lastBrushY = None
# Update status label
if hasattr(self, '_brushStatusLabel'):
self._brushStatusLabel.config(text="Inactive", fg="red")
def _drawBrushPoint(self, x: int, y: int) -> None:
"""Draw a single brush point at the given canvas coordinates."""
if self._currentImage is None:
return
# Convert widget coordinates to canvas coordinates (accounts for borders/scroll)
canvas_x = self._imageCanvas.canvasx(x)
canvas_y = self._imageCanvas.canvasy(y)
# Get image dimensions
pil_image = self._currentImage.getImage()
img_width, img_height = pil_image.size
# Use stored canvas dimensions if available, otherwise use image dimensions
if self._canvasImageWidth and self._canvasImageHeight:
canvas_width = self._canvasImageWidth
canvas_height = self._canvasImageHeight
else:
canvas_width = img_width
canvas_height = img_height
# Convert canvas coordinates to image coordinates
# Since image is placed at (0,0) with anchor="nw", coordinates align directly
img_x = int(canvas_x)
img_y = int(canvas_y)
# Ensure coordinates are within bounds
img_x = max(0, min(img_x, img_width - 1))
img_y = max(0, min(img_y, img_height - 1))
# Convert hex color to RGB tuple
hex_color = self._brushColor.lstrip('#')
rgb_color = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
# Draw on the image
draw = ImageDraw.Draw(pil_image)
# Draw an ellipse (circle) for the brush point
radius = self._brushSize // 2
draw.ellipse([img_x - radius, img_y - radius, img_x + radius, img_y + radius],
fill=rgb_color, outline=rgb_color)
# Update the image
self._currentImage._imageData = pil_image
# Re-render
self._renderCurrentImage()
def _drawBrushLine(self, x1: int, y1: int, x2: int, y2: int) -> None:
"""Draw a brush line between two canvas coordinates."""
if self._currentImage is None:
return
# Convert widget coordinates to canvas coordinates (accounts for borders/scroll)
canvas_x1 = self._imageCanvas.canvasx(x1)
canvas_y1 = self._imageCanvas.canvasy(y1)
canvas_x2 = self._imageCanvas.canvasx(x2)
canvas_y2 = self._imageCanvas.canvasy(y2)
# Get image dimensions
pil_image = self._currentImage.getImage()
img_width, img_height = pil_image.size
# Use stored canvas dimensions if available, otherwise use image dimensions
if self._canvasImageWidth and self._canvasImageHeight:
canvas_width = self._canvasImageWidth
canvas_height = self._canvasImageHeight
else:
canvas_width = img_width
canvas_height = img_height
# Convert canvas coordinates to image coordinates
# Since image is placed at (0,0) with anchor="nw", coordinates align directly
img_x1 = int(canvas_x1)
img_y1 = int(canvas_y1)
img_x2 = int(canvas_x2)
img_y2 = int(canvas_y2)
# Ensure coordinates are within bounds
img_x1 = max(0, min(img_x1, img_width - 1))
img_y1 = max(0, min(img_y1, img_height - 1))
img_x2 = max(0, min(img_x2, img_width - 1))
img_y2 = max(0, min(img_y2, img_height - 1))
# Convert hex color to RGB tuple
hex_color = self._brushColor.lstrip('#')
rgb_color = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
# Draw on the image
draw = ImageDraw.Draw(pil_image)
# Draw a line with rounded ends
radius = self._brushSize // 2
# Draw the line itself
draw.line([(img_x1, img_y1), (img_x2, img_y2)], fill=rgb_color, width=self._brushSize)
# Draw rounded ends (circles at start and end)
draw.ellipse([img_x1 - radius, img_y1 - radius, img_x1 + radius, img_y1 + radius],
fill=rgb_color)
draw.ellipse([img_x2 - radius, img_y2 - radius, img_x2 + radius, img_y2 + radius],
fill=rgb_color)
# Update the image
self._currentImage._imageData = pil_image
# Re-render
self._renderCurrentImage()
def _openCamera(self):
camera = CameraWindow()
camera.run(self)