368 lines
14 KiB
Python
368 lines
14 KiB
Python
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
|
|
|