import tkinter as tk from tkinter import colorchooser from PIL import ImageDraw from typing import Callable, Optional import sys import os # Add parent directory to path for imports (since we're in utils/ subdirectory) _parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if _parent_dir not in sys.path: sys.path.insert(0, _parent_dir) class BrushHandler: """Handler for brush functionality in the GUI. This class encapsulates all brush operations including drawing, color selection, size adjustment, and mode toggling. """ def __init__(self, canvas: tk.Canvas, get_current_image: Callable, get_canvas_dimensions: Callable, render_image: Callable, root_window: tk.Tk, area_selection_handler=None): """Initialize the brush handler. Args: canvas: The tkinter Canvas widget get_current_image: Function that returns the current ImageContainer get_canvas_dimensions: Function that returns (width, height) tuple render_image: Function to re-render the current image root_window: The root tkinter window area_selection_handler: Optional AreaSelectionHandler to disable when brush is active """ self._canvas = canvas self._get_current_image = get_current_image self._get_canvas_dimensions = get_canvas_dimensions self._render_image = render_image self._root = root_window self._area_selection_handler = area_selection_handler # Brush state self._brushMode = False self._brushColor = "#000000" # Default black self._brushSize = 5 # Default brush size self._lastBrushX = None self._lastBrushY = None self._isDrawing = False self._snapshotTaken = False # Track if snapshot was taken for current stroke # UI element references (will be set by GUI) self._colorPreview = None self._brushStatusLabel = None self._colorSwatches = [] # List of color swatch buttons def create_ui_panel(self, parent_frame: tk.Frame) -> None: """Create the brush settings UI panel. Args: parent_frame: The parent frame to pack the UI into """ # Brush settings title tk.Label(parent_frame, text="Brush Settings", font=("Arial", 12, "bold"), bg="lightgray").pack(pady=15) # Color selection section color_section = tk.Frame(parent_frame, 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 preview preview_frame = tk.Frame(color_section, bg="lightgray") preview_frame.pack(pady=5, fill="x") self._colorPreview = tk.Label(preview_frame, bg=self._brushColor, width=20, height=2, relief="sunken", bd=2) self._colorPreview.pack(pady=5) # Color palette palette_frame = tk.Frame(color_section, bg="lightgray") palette_frame.pack(pady=5, fill="x") # Define color palette - common colors arranged in a grid color_palette = [ # Row 1: Basic colors ["#000000", "#FFFFFF", "#FF0000", "#00FF00", "#0000FF", "#FFFF00"], # Row 2: Secondary colors ["#FF00FF", "#00FFFF", "#FFA500", "#800080", "#FFC0CB", "#A52A2A"], # Row 3: Grays and browns ["#808080", "#C0C0C0", "#D3D3D3", "#8B4513", "#654321", "#DEB887"], # Row 4: Blues and greens ["#000080", "#008080", "#008000", "#00CED1", "#4682B4", "#32CD32"], # Row 5: Reds and oranges ["#DC143C", "#FF4500", "#FF6347", "#FF1493", "#8B0000", "#CD5C5C"], # Row 6: Yellows and purples ["#FFD700", "#FFA500", "#DA70D6", "#9370DB", "#4B0082", "#9932CC"] ] # Create color swatches self._colorSwatches = [] for row_idx, row_colors in enumerate(color_palette): row_frame = tk.Frame(palette_frame, bg="lightgray") row_frame.pack(pady=2) row_swatches = [] for col_idx, color in enumerate(row_colors): swatch = tk.Button( row_frame, bg=color, activebackground=color, width=3, height=1, relief="raised", bd=2, cursor="hand2", command=lambda c=color: self._select_color(c) ) swatch.pack(side="left", padx=1, pady=1) row_swatches.append(swatch) self._colorSwatches.append(row_swatches) # Custom color button (still allow custom color selection) custom_frame = tk.Frame(color_section, bg="lightgray") custom_frame.pack(pady=5, fill="x") def choose_custom_color(): color = colorchooser.askcolor(title="Choose Custom Color", color=self._brushColor) if color[1]: # color[1] is the hex string self._select_color(color[1]) tk.Button(custom_frame, text="Custom Color...", command=choose_custom_color, width=15).pack(pady=2) # Initialize color selection highlighting self._select_color(self._brushColor) # Size selection section size_section = tk.Frame(parent_frame, 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(parent_frame, 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) def _select_color(self, color: str) -> None: """Select a color from the palette or custom color. Args: color: Hex color string (e.g., "#FF0000") """ self._brushColor = color if self._colorPreview: self._colorPreview.config(bg=self._brushColor) # Update visual feedback on swatches (highlight selected if in palette) color_found = False for row_swatches in self._colorSwatches: for swatch in row_swatches: swatch_color = swatch.cget("bg") if swatch_color.upper() == color.upper(): swatch.config(relief="sunken", bd=3) color_found = True else: swatch.config(relief="raised", bd=2) # If custom color not in palette, all swatches remain raised (normal state) def toggle_brush_mode(self) -> None: """Toggle brush mode on/off.""" self._brushMode = not self._brushMode if self._brushMode: # Disable selection mode when brush mode is enabled if self._area_selection_handler and self._area_selection_handler.selection_mode: self._area_selection_handler.toggle_selection_mode() self._root.config(cursor="pencil") # Update status label if 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 self._brushStatusLabel: self._brushStatusLabel.config(text="Inactive", fg="red") def on_mouse_click(self, event) -> bool: """Handle mouse click for brush. Returns: True if the click was handled by brush, False otherwise """ if not self._brushMode: return False current_image = self._get_current_image() if current_image is None: return False self._isDrawing = True self._lastBrushX = event.x self._lastBrushY = event.y # Take snapshot for undo (only once per stroke) if not self._snapshotTaken: current_image.snapshot() self._snapshotTaken = True # Draw first point self._draw_brush_point(event.x, event.y) return True def on_mouse_drag(self, event) -> bool: """Handle mouse drag for brush. Returns: True if the drag was handled by brush, False otherwise """ if not self._brushMode or not self._isDrawing: return False self._draw_brush_line(self._lastBrushX, self._lastBrushY, event.x, event.y) self._lastBrushX = event.x self._lastBrushY = event.y return True def on_mouse_release(self, event) -> bool: """Handle mouse release to stop brush. Returns: True if the release was handled by brush, False otherwise """ if not self._brushMode or not self._isDrawing: return False self._isDrawing = False self._lastBrushX = None self._lastBrushY = None self._snapshotTaken = False # Reset for next stroke return True def _draw_brush_point(self, x: int, y: int) -> None: """Draw a single brush point at the given canvas coordinates.""" current_image = self._get_current_image() if current_image is None: return # Convert widget coordinates to canvas coordinates (accounts for borders/scroll) canvas_x = self._canvas.canvasx(x) canvas_y = self._canvas.canvasy(y) # Get image dimensions pil_image = current_image.getImage() img_width, img_height = pil_image.size # Get canvas dimensions canvas_width, canvas_height = self._get_canvas_dimensions() # 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 current_image._imageData = pil_image # Re-render self._render_image() def _draw_brush_line(self, x1: int, y1: int, x2: int, y2: int) -> None: """Draw a brush line between two canvas coordinates.""" current_image = self._get_current_image() if current_image is None: return # Convert widget coordinates to canvas coordinates (accounts for borders/scroll) canvas_x1 = self._canvas.canvasx(x1) canvas_y1 = self._canvas.canvasy(y1) canvas_x2 = self._canvas.canvasx(x2) canvas_y2 = self._canvas.canvasy(y2) # Get image dimensions pil_image = current_image.getImage() img_width, img_height = pil_image.size # Get canvas dimensions canvas_width, canvas_height = self._get_canvas_dimensions() # 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 current_image._imageData = pil_image # Re-render self._render_image() @property def brush_mode(self) -> bool: """Get the current brush mode state.""" return self._brushMode