moving area selection, text input, and brush functionalities out of GUI.py and into their own files in the utils directory

This commit is contained in:
vb
2025-11-06 09:44:34 +01:00
parent fd7d3bb537
commit 3916513cfd
4 changed files with 1033 additions and 739 deletions

View File

@@ -4,6 +4,9 @@ from PIL import ImageTk, ImageDraw, Image
from ImageContainer import ImageContainer
from ImageManipulation.ManipulationList import *
from SelectionArea import SelectionArea
from utils.area_selection import AreaSelectionHandler
from utils.brush import BrushHandler
from utils.text_entry import TextEntryHandler
from functools import partial
@@ -19,26 +22,15 @@ class GUI:
_currentImage = None
# Interactive selection variables
_selectionMode = False
_selectionType = "rectangle" # "rectangle", "circle", or "lasso"
_selectionStartX = None
_selectionStartY = None
_selectionEndX = None
_selectionEndY = None
_selectionRectangle = None
_selectionArea = None
_lassoPath = [] # List of canvas coordinates for lasso drawing
_lassoLine = None # Canvas line item for lasso path
# Area selection handler (will be initialized in initialise())
_areaSelectionHandler = None
# Brush handler (will be initialized in initialise())
_brushHandler = None
# Text entry handler (will be initialized in initialise())
_textEntryHandler = 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
@@ -122,7 +114,7 @@ class GUI:
# Label to hold the image (will be placed on canvas)
self._imageLabel = tk.Label(self._imageCanvas, bg="white")
# Bind mouse events for interactive selection
# Bind mouse events for interactive selection and brush
self._imageCanvas.bind("<Button-1>", self._onMouseClick)
self._imageCanvas.bind("<B1-Motion>", self._onMouseDrag)
self._imageCanvas.bind("<ButtonRelease-1>", self._onMouseRelease)
@@ -132,66 +124,49 @@ class GUI:
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
self._selectionArea = SelectionArea()
selection_area = SelectionArea()
# Initialize area selection handler
def get_current_image():
return self._currentImage
def get_canvas_dimensions():
if self._canvasImageWidth and self._canvasImageHeight:
return (self._canvasImageWidth, self._canvasImageHeight)
else:
canvas_width = self._imageCanvas.winfo_width()
canvas_height = self._imageCanvas.winfo_height()
return (canvas_width, canvas_height)
# Area selection handler from utils/area_selection.py
self._areaSelectionHandler = AreaSelectionHandler(
canvas=self._imageCanvas,
get_current_image=get_current_image,
get_canvas_dimensions=get_canvas_dimensions,
render_image=self._renderCurrentImage,
root_window=root,
selection_area=selection_area
)
# Brush handler from utils/brush.py
self._brushHandler = BrushHandler(
canvas=self._imageCanvas,
get_current_image=get_current_image,
get_canvas_dimensions=get_canvas_dimensions,
render_image=self._renderCurrentImage,
root_window=root,
area_selection_handler=self._areaSelectionHandler
)
# Create brush UI panel
self._brushHandler.create_ui_panel(settings_panel)
# Text entry handler from utils/text_entry.py
self._textEntryHandler = TextEntryHandler(root)
# Key bindings
root.bind_all('<Control-z>', lambda event: self._undo())
@@ -202,7 +177,7 @@ class GUI:
root.bind_all('<Control-q>', lambda event: root.quit())
root.bind_all('<Control-Shift-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.bind_all('<Control-b>', lambda event: self._toggleBrushMode() if self._brushHandler else None)
root.mainloop()
@@ -242,8 +217,8 @@ class GUI:
self._imageCanvas.config(width=width, height=height)
# If there's an active selection, draw it
if self._selectionArea.is_active and self._selectionArea.is_valid():
self._drawSelectionHighlight()
if self._areaSelectionHandler and self._areaSelectionHandler.selection_area.is_active and self._areaSelectionHandler.selection_area.is_valid():
self._areaSelectionHandler.draw_selection_highlight()
def _applyManipulation(self, manipulation, params: dict | None = None) -> None:
if self._currentImage is None:
@@ -251,9 +226,10 @@ class GUI:
# Special handling for AddText - show dialog to get text input
if manipulation.getManipulationName() == "Add Text" and params is None:
params = self._showTextInputDialog()
if params is None: # User cancelled
return
if self._textEntryHandler:
params = self._textEntryHandler.show_text_input_dialog()
if params is None: # User cancelled
return
# Take undo snapshot
self._currentImage.snapshot()
@@ -287,381 +263,49 @@ class GUI:
def _toggleSelectionMode(self) -> None:
"""Toggle interactive selection mode on/off."""
self._selectionMode = not self._selectionMode
if self._selectionMode:
self._root.config(cursor="crosshair")
else:
self._root.config(cursor="")
self._clearSelection()
if self._areaSelectionHandler:
self._areaSelectionHandler.toggle_selection_mode()
def _toggleSelectionShape(self) -> None:
"""Toggle selection shape between rectangle, circle, and lasso."""
if self._selectionArea is None:
return
# Cycle through: rectangle -> circle -> lasso -> rectangle
current_shape = self._selectionArea.shape
if current_shape == "rectangle":
new_shape = "circle"
elif current_shape == "circle":
new_shape = "lasso"
else: # lasso
new_shape = "rectangle"
self._selectionArea.set_shape(new_shape)
self._selectionType = new_shape
# Refresh selection drawing if active
if self._selectionArea.is_active and self._selectionArea.is_valid():
self._renderCurrentImage()
if self._areaSelectionHandler:
self._areaSelectionHandler.toggle_selection_shape()
def _onMouseClick(self, event) -> None:
"""Handle mouse click for selection or brush."""
if self._brushMode and self._currentImage is None:
# Handle brush mode - delegate to handler
if self._brushHandler and self._brushHandler.on_mouse_click(event):
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
# Handle lasso mode
if self._selectionType == "lasso":
# Clear any existing selection
self._clearSelection()
# Start new lasso path
self._lassoPath = []
canvas_x = self._imageCanvas.canvasx(event.x)
canvas_y = self._imageCanvas.canvasy(event.y)
self._lassoPath.append((canvas_x, canvas_y))
return
# Handle rectangle/circle mode
# Clear any existing selection
self._clearSelection()
# Store starting coordinates
self._selectionStartX = event.x
self._selectionStartY = event.y
# Handle selection mode - delegate to handler
if self._areaSelectionHandler:
self._areaSelectionHandler.on_mouse_click(event)
def _onMouseDrag(self, event) -> None:
"""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
# Handle brush mode - delegate to handler
if self._brushHandler and self._brushHandler.on_mouse_drag(event):
return
# Handle selection mode
if not self._selectionMode or self._currentImage is None:
return
# Handle lasso mode
if self._selectionType == "lasso" and self._lassoPath:
# Add point to lasso path
canvas_x = self._imageCanvas.canvasx(event.x)
canvas_y = self._imageCanvas.canvasy(event.y)
self._lassoPath.append((canvas_x, canvas_y))
# Draw lasso path
if len(self._lassoPath) > 1:
# Clear previous lasso line
if self._lassoLine:
self._imageCanvas.delete(self._lassoLine)
# Draw new lasso path
self._lassoLine = self._imageCanvas.create_line(
*[coord for point in self._lassoPath for coord in point],
fill="red", width=3, smooth=False
)
return
# Handle rectangle/circle mode
if self._selectionStartX is None:
return
# Clear previous shape
if self._selectionRectangle:
self._imageCanvas.delete(self._selectionRectangle)
# Draw shape outline depending on selection shape
if self._selectionArea.shape == "circle":
self._selectionRectangle = self._imageCanvas.create_oval(
self._selectionStartX, self._selectionStartY, event.x, event.y,
outline="red", width=3, fill=""
)
else:
self._selectionRectangle = self._imageCanvas.create_rectangle(
self._selectionStartX, self._selectionStartY, event.x, event.y,
outline="red", width=3, fill=""
)
# Handle selection mode - delegate to handler
if self._areaSelectionHandler:
self._areaSelectionHandler.on_mouse_drag(event)
def _onMouseRelease(self, event) -> None:
"""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
# Handle brush mode - delegate to handler
if self._brushHandler and self._brushHandler.on_mouse_release(event):
return
# Handle selection mode
if not self._selectionMode or self._currentImage is None:
return
# Handle lasso mode
if self._selectionType == "lasso" and self._lassoPath:
if len(self._lassoPath) >= 3:
# Convert lasso path from canvas coordinates to image coordinates
pil_image = self._currentImage.getImage()
img_width, img_height = pil_image.size
# Get canvas dimensions for scaling
if self._canvasImageWidth and self._canvasImageHeight:
canvas_width = self._canvasImageWidth
canvas_height = self._canvasImageHeight
else:
canvas_width = self._imageCanvas.winfo_width()
canvas_height = self._imageCanvas.winfo_height()
if canvas_width <= 0 or canvas_height <= 0:
return
# Calculate scaling factors
scale_x = img_width / canvas_width
scale_y = img_height / canvas_height
# Convert canvas coordinates to image coordinates
image_path = [(int(x * scale_x), int(y * scale_y)) for x, y in self._lassoPath]
# Set the lasso path in selection area
self._selectionArea.set_lasso_path(image_path)
# Redraw the selection highlight to ensure it's visible
self._renderCurrentImage()
return
# Handle rectangle/circle mode
if self._selectionStartX is None:
return
# Store ending coordinates
self._selectionEndX = event.x
self._selectionEndY = event.y
# Convert screen coordinates to image coordinates
selection_coords = self._convertToImageCoordinates(
self._selectionStartX, self._selectionStartY,
self._selectionEndX, self._selectionEndY
)
if selection_coords:
# Set the selection area
self._selectionArea.set_coordinates(
selection_coords["left"], selection_coords["top"],
selection_coords["right"], selection_coords["bottom"]
)
# Redraw the selection highlight to ensure it's visible
self._renderCurrentImage()
# Handle selection mode - delegate to handler
if self._areaSelectionHandler:
self._areaSelectionHandler.on_mouse_release(event)
def _onRightClick(self, event) -> None:
"""Handle right-click to show context menu for manipulation options."""
if not self._selectionArea or not self._selectionArea.is_active:
return
if not self._selectionArea.is_valid():
return
# Create context menu with all available manipulations
context_menu = tk.Menu(self._root, tearoff=0)
# Add crop to selection option first (most common use case)
context_menu.add_command(label="Crop to Selection", command=self._cropToSelection)
context_menu.add_separator()
# Add manipulation options
manipulations = GetImageManipulationList()
for manipulation in manipulations:
manipulation_name = manipulation.getManipulationName()
context_menu.add_command(
label=f"Apply {manipulation_name} to Selection",
command=lambda m=manipulation: self._applyManipulationToSelection(m)
)
context_menu.add_separator()
context_menu.add_command(label="Clear Selection", command=self._clearSelection)
# Show context menu at cursor position
try:
context_menu.tk_popup(event.x_root, event.y_root)
finally:
context_menu.grab_release()
if self._areaSelectionHandler:
self._areaSelectionHandler.on_right_click(event, self._getDefaultParams)
def _convertToImageCoordinates(self, start_x, start_y, end_x, end_y):
"""Convert screen coordinates to image coordinates."""
if self._currentImage is None:
return None
pil_image = self._currentImage.getImage()
if pil_image is None:
return None
# Convert widget coordinates to canvas coordinates (accounts for borders/scroll)
canvas_x1 = self._imageCanvas.canvasx(start_x)
canvas_y1 = self._imageCanvas.canvasy(start_y)
canvas_x2 = self._imageCanvas.canvasx(end_x)
canvas_y2 = self._imageCanvas.canvasy(end_y)
# Get image dimensions
img_width, img_height = pil_image.size
# Get canvas dimensions for scaling
# Use stored canvas image dimensions if available (from when image was rendered)
if self._canvasImageWidth and self._canvasImageHeight:
canvas_width = self._canvasImageWidth
canvas_height = self._canvasImageHeight
else:
# Fallback: use actual canvas widget dimensions
canvas_width = self._imageCanvas.winfo_width()
canvas_height = self._imageCanvas.winfo_height()
if canvas_width <= 0 or canvas_height <= 0:
return None
# Calculate scaling factors to convert canvas coordinates to image coordinates
scale_x = img_width / canvas_width
scale_y = img_height / canvas_height
# Convert canvas coordinates to image coordinates
left = int(min(canvas_x1, canvas_x2) * scale_x)
top = int(min(canvas_y1, canvas_y2) * scale_y)
right = int(max(canvas_x1, canvas_x2) * scale_x)
bottom = int(max(canvas_y1, canvas_y2) * scale_y)
# Ensure coordinates are within image bounds
left = max(0, min(left, img_width))
top = max(0, min(top, img_height))
right = max(left, min(right, img_width))
bottom = max(top, min(bottom, img_height))
# Only return valid selection coordinates
if right > left and bottom > top:
return {"left": left, "top": top, "right": right, "bottom": bottom}
return None
def _cropToSelection(self) -> None:
"""Crop the entire image to the selected area."""
if self._currentImage is None or not self._selectionArea or not self._selectionArea.is_active:
return
# Get the selection coordinates
coords = self._selectionArea.get_coordinates()
if not coords:
return
left, top, right, bottom = coords
# Validate coordinates
if right <= left or bottom <= top:
return
# Get the original image
original_image = self._currentImage.getImage()
if original_image is None:
return
img_width, img_height = original_image.size
# Ensure coordinates are within image bounds
left = max(0, min(left, img_width))
top = max(0, min(top, img_height))
right = max(left, min(right, img_width))
bottom = max(top, min(bottom, img_height))
# Validate final coordinates
if right <= left or bottom <= top:
return
try:
# Take undo snapshot
self._currentImage.snapshot()
# Use SelectionArea's crop_image method which handles all shapes including lasso
cropped_image = self._selectionArea.crop_image(original_image)
if cropped_image is None:
return
# For lasso, we get an image with transparency, so convert to RGB if needed
# Otherwise keep the cropped image as is
if self._selectionArea.shape == "lasso" and cropped_image.mode == "RGBA":
# Create a white background for lasso crops
background = Image.new("RGB", cropped_image.size, (255, 255, 255))
background.paste(cropped_image, (0, 0), cropped_image.split()[3] if cropped_image.mode == "RGBA" else None)
cropped_image = background
# Update the current image
self._currentImage._imageData = cropped_image
# Clear selection before re-rendering
self._clearSelection()
# Re-render the image (without selection)
self._renderCurrentImage()
except Exception as e:
# Silently handle errors - crop operation failed
pass
def _applyManipulationToSelection(self, manipulation) -> None:
"""Apply the specified manipulation to the selected area only."""
if self._currentImage is None or not self._selectionArea.is_active:
return
# Take undo snapshot
self._currentImage.snapshot()
# Get the original image
original_image = self._currentImage.getImage()
# Crop the selection area
selected_area = self._selectionArea.crop_image(original_image)
if selected_area is None:
return
# Create a temporary ImageContainer for the selected area
temp_container = ImageContainer()
temp_container._imageData = selected_area
# Apply manipulation to the selected area
# Use default parameters if none specified
params = self._getDefaultParams(manipulation)
manipulation.manipulateImage(temp_container, params)
# Get the processed selection
processed_selection = temp_container.getImage()
# Apply the processed selection back to the original image
result_image = self._selectionArea.apply_to_image(original_image, processed_selection)
# Update the current image
self._currentImage._imageData = result_image
# Clear selection before re-rendering
self._clearSelection()
# Re-render the image (without selection)
self._renderCurrentImage()
def _getDefaultParams(self, manipulation) -> dict:
"""Get default parameters for a manipulation."""
@@ -692,315 +336,13 @@ class GUI:
else:
return {}
def _showTextInputDialog(self) -> dict | None:
"""Show a dialog window for text input and parameters."""
dialog = tk.Toplevel(self._root)
dialog.title("Add Text to Image")
dialog.geometry("400x350")
dialog.resizable(False, False)
dialog.transient(self._root)
dialog.grab_set() # Make dialog modal
# Center the dialog
dialog.update_idletasks()
x = (dialog.winfo_screenwidth() // 2) - (dialog.winfo_width() // 2)
y = (dialog.winfo_screenheight() // 2) - (dialog.winfo_height() // 2)
dialog.geometry(f"+{x}+{y}")
result = {"text": "", "x": 10, "y": 10, "font_size": 40, "color": (0, 0, 0)}
dialog_closed = [False] # Use list to allow modification in nested functions
# Text input field
tk.Label(dialog, text="Text:", font=("Arial", 10)).pack(pady=(20, 5), padx=20, anchor="w")
text_entry = tk.Text(dialog, height=3, width=40, wrap=tk.WORD, font=("Arial", 10))
text_entry.pack(padx=20, pady=(0, 10), fill="x")
text_entry.focus()
# Position and size frame
options_frame = tk.Frame(dialog)
options_frame.pack(padx=20, pady=10, fill="x")
# X position
x_frame = tk.Frame(options_frame)
x_frame.pack(fill="x", pady=5)
tk.Label(x_frame, text="X Position:", width=12, anchor="w").pack(side="left")
x_entry = tk.Entry(x_frame, width=10)
x_entry.pack(side="left", padx=5)
x_entry.insert(0, "10")
# Y position
y_frame = tk.Frame(options_frame)
y_frame.pack(fill="x", pady=5)
tk.Label(y_frame, text="Y Position:", width=12, anchor="w").pack(side="left")
y_entry = tk.Entry(y_frame, width=10)
y_entry.pack(side="left", padx=5)
y_entry.insert(0, "10")
# Font size
size_frame = tk.Frame(options_frame)
size_frame.pack(fill="x", pady=5)
tk.Label(size_frame, text="Font Size:", width=12, anchor="w").pack(side="left")
size_entry = tk.Entry(size_frame, width=10)
size_entry.pack(side="left", padx=5)
size_entry.insert(0, "40")
# Color selection
color_frame = tk.Frame(options_frame)
color_frame.pack(fill="x", pady=5)
tk.Label(color_frame, text="Color:", width=12, anchor="w").pack(side="left")
color_preview = tk.Label(color_frame, bg="#000000", width=8, height=1, relief="sunken", bd=2)
color_preview.pack(side="left", padx=5)
selected_color = [(0, 0, 0)] # Use list to allow modification
def choose_color():
color = colorchooser.askcolor(title="Choose Text Color", color="#000000")
if color[1]: # color[1] is the hex string
selected_color[0] = tuple(int(color[1][i:i+2], 16) for i in (1, 3, 5))
color_preview.config(bg=color[1])
tk.Button(color_frame, text="Choose Color", command=choose_color, width=12).pack(side="left", padx=5)
# Buttons
button_frame = tk.Frame(dialog)
button_frame.pack(pady=20)
def on_ok():
text = text_entry.get("1.0", tk.END).strip()
if not text:
messagebox.showwarning("No Text", "Please enter some text.")
return
try:
result["text"] = text
result["x"] = int(x_entry.get() or "10")
result["y"] = int(y_entry.get() or "10")
result["font_size"] = int(size_entry.get() or "40")
result["color"] = selected_color[0]
dialog_closed[0] = True
dialog.destroy()
except ValueError:
messagebox.showerror("Invalid Input", "Please enter valid numbers for position and font size.")
def on_cancel():
dialog_closed[0] = True
dialog.destroy()
tk.Button(button_frame, text="OK", command=on_ok, width=10).pack(side="left", padx=5)
tk.Button(button_frame, text="Cancel", command=on_cancel, width=10).pack(side="left", padx=5)
# Handle Enter key (Ctrl+Enter for Text widget, Enter for dialog)
text_entry.bind("<Control-Return>", lambda e: on_ok())
dialog.bind("<Return>", lambda e: on_ok())
dialog.bind("<Escape>", lambda e: on_cancel())
# Wait for dialog to close
dialog.wait_window()
# Return None if cancelled, otherwise return result
if not dialog_closed[0] or not result["text"]:
return None
return result
def _clearSelection(self) -> None:
"""Clear the selection rectangle and reset selection state."""
if self._selectionRectangle:
self._imageCanvas.delete(self._selectionRectangle)
self._selectionRectangle = None
if self._lassoLine:
self._imageCanvas.delete(self._lassoLine)
self._lassoLine = None
self._lassoPath = []
self._selectionStartX = None
self._selectionStartY = None
self._selectionEndX = None
self._selectionEndY = None
self._selectionArea.clear()
def _drawSelectionHighlight(self) -> None:
"""Draw a colored highlight over the selected area."""
if not self._selectionArea.is_active or not self._selectionArea.is_valid():
return
coords = self._selectionArea.get_coordinates()
if not coords:
return
left, top, right, bottom = coords
# Convert image coordinates to canvas coordinates
pil_image = self._currentImage.getImage()
img_width, img_height = pil_image.size
# Get canvas dimensions for scaling
# Use stored canvas image dimensions if available (from when image was rendered)
if self._canvasImageWidth and self._canvasImageHeight:
canvas_width = self._canvasImageWidth
canvas_height = self._canvasImageHeight
else:
# Fallback: use actual canvas widget dimensions
canvas_width = self._imageCanvas.winfo_width()
canvas_height = self._imageCanvas.winfo_height()
if canvas_width <= 0 or canvas_height <= 0:
return
# Calculate scaling factors
scale_x = canvas_width / img_width
scale_y = canvas_height / img_height
# Convert coordinates
canvas_left = int(left * scale_x)
canvas_top = int(top * scale_y)
canvas_right = int(right * scale_x)
canvas_bottom = int(bottom * scale_y)
# Draw colored edges only
if self._selectionArea.shape == "lasso" and self._selectionArea.lasso_path:
# Convert lasso path from image coordinates to canvas coordinates
canvas_path = [(int(x * scale_x), int(y * scale_y)) for x, y in self._selectionArea.lasso_path]
if len(canvas_path) >= 3:
# Close the path by adding the first point at the end
closed_path = canvas_path + [canvas_path[0]]
self._imageCanvas.create_line(
*[coord for point in closed_path for coord in point],
fill="red", width=3, smooth=False
)
elif self._selectionArea.shape == "circle":
self._imageCanvas.create_oval(
canvas_left, canvas_top, canvas_right, canvas_bottom,
outline="red", width=3, fill=""
)
else:
self._imageCanvas.create_rectangle(
canvas_left, canvas_top, canvas_right, canvas_bottom,
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")
if self._brushHandler:
self._brushHandler.toggle_brush_mode()
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()

523
src/utils/area_selection.py Normal file
View File

@@ -0,0 +1,523 @@
import tkinter as tk
from tkinter import Menu
from PIL import Image
from typing import Optional, Callable
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)
from SelectionArea import SelectionArea
from ImageContainer import ImageContainer
from ImageManipulation.ManipulationList import GetImageManipulationList
class AreaSelectionHandler:
"""Handler for area selection functionality in the GUI.
This class encapsulates all area selection operations including rectangle,
circle, and lasso selections, coordinate conversion, and applying
manipulations to selected areas.
"""
def __init__(self,
canvas: tk.Canvas,
get_current_image: Callable,
get_canvas_dimensions: Callable,
render_image: Callable,
root_window: tk.Tk,
selection_area: SelectionArea):
"""Initialize the area selection handler.
Args:
canvas: The tkinter Canvas widget for drawing selections
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
selection_area: The SelectionArea instance to manage
"""
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._selectionArea = selection_area
# Selection state
self._selectionMode = False
self._selectionType = "rectangle" # "rectangle", "circle", or "lasso"
self._selectionStartX = None
self._selectionStartY = None
self._selectionEndX = None
self._selectionEndY = None
self._selectionRectangle = None
self._lassoPath = [] # List of canvas coordinates for lasso drawing
self._lassoLine = None # Canvas line item for lasso path
def toggle_selection_mode(self) -> None:
"""Toggle interactive selection mode on/off."""
self._selectionMode = not self._selectionMode
if self._selectionMode:
self._root.config(cursor="crosshair")
else:
self._root.config(cursor="")
self.clear_selection()
def toggle_selection_shape(self) -> None:
"""Toggle selection shape between rectangle, circle, and lasso."""
if self._selectionArea is None:
return
# Cycle through: rectangle -> circle -> lasso -> rectangle
current_shape = self._selectionArea.shape
if current_shape == "rectangle":
new_shape = "circle"
elif current_shape == "circle":
new_shape = "lasso"
else: # lasso
new_shape = "rectangle"
self._selectionArea.set_shape(new_shape)
self._selectionType = new_shape
# Refresh selection drawing if active
if self._selectionArea.is_active and self._selectionArea.is_valid():
self._render_image()
def on_mouse_click(self, event) -> bool:
"""Handle mouse click for selection.
Returns:
True if the click was handled by selection, False otherwise
"""
# Handle selection mode
if not self._selectionMode:
return False
current_image = self._get_current_image()
if current_image is None:
return False
# Handle lasso mode
if self._selectionType == "lasso":
# Clear any existing selection
self.clear_selection()
# Start new lasso path
self._lassoPath = []
canvas_x = self._canvas.canvasx(event.x)
canvas_y = self._canvas.canvasy(event.y)
self._lassoPath.append((canvas_x, canvas_y))
return True
# Handle rectangle/circle mode
# Clear any existing selection
self.clear_selection()
# Store starting coordinates
self._selectionStartX = event.x
self._selectionStartY = event.y
return True
def on_mouse_drag(self, event) -> bool:
"""Handle mouse drag for selection.
Returns:
True if the drag was handled by selection, False otherwise
"""
# Handle selection mode
if not self._selectionMode:
return False
current_image = self._get_current_image()
if current_image is None:
return False
# Handle lasso mode
if self._selectionType == "lasso" and self._lassoPath:
# Add point to lasso path
canvas_x = self._canvas.canvasx(event.x)
canvas_y = self._canvas.canvasy(event.y)
self._lassoPath.append((canvas_x, canvas_y))
# Draw lasso path
if len(self._lassoPath) > 1:
# Clear previous lasso line
if self._lassoLine:
self._canvas.delete(self._lassoLine)
# Draw new lasso path
self._lassoLine = self._canvas.create_line(
*[coord for point in self._lassoPath for coord in point],
fill="red", width=3, smooth=False
)
return True
# Handle rectangle/circle mode
if self._selectionStartX is None:
return False
# Clear previous shape
if self._selectionRectangle:
self._canvas.delete(self._selectionRectangle)
# Draw shape outline depending on selection shape
if self._selectionArea.shape == "circle":
self._selectionRectangle = self._canvas.create_oval(
self._selectionStartX, self._selectionStartY, event.x, event.y,
outline="red", width=3, fill=""
)
else:
self._selectionRectangle = self._canvas.create_rectangle(
self._selectionStartX, self._selectionStartY, event.x, event.y,
outline="red", width=3, fill=""
)
return True
def on_mouse_release(self, event) -> bool:
"""Handle mouse release to finalize selection.
Returns:
True if the release was handled by selection, False otherwise
"""
# Handle selection mode
if not self._selectionMode:
return False
current_image = self._get_current_image()
if current_image is None:
return False
# Handle lasso mode
if self._selectionType == "lasso" and self._lassoPath:
if len(self._lassoPath) >= 3:
# Convert lasso path from canvas coordinates to image coordinates
pil_image = current_image.getImage()
img_width, img_height = pil_image.size
# Get canvas dimensions for scaling
canvas_width, canvas_height = self._get_canvas_dimensions()
if canvas_width <= 0 or canvas_height <= 0:
return True
# Calculate scaling factors
scale_x = img_width / canvas_width
scale_y = img_height / canvas_height
# Convert canvas coordinates to image coordinates
image_path = [(int(x * scale_x), int(y * scale_y)) for x, y in self._lassoPath]
# Set the lasso path in selection area
self._selectionArea.set_lasso_path(image_path)
# Redraw the selection highlight to ensure it's visible
self._render_image()
return True
# Handle rectangle/circle mode
if self._selectionStartX is None:
return False
# Store ending coordinates
self._selectionEndX = event.x
self._selectionEndY = event.y
# Convert screen coordinates to image coordinates
selection_coords = self._convert_to_image_coordinates(
self._selectionStartX, self._selectionStartY,
self._selectionEndX, self._selectionEndY
)
if selection_coords:
# Set the selection area
self._selectionArea.set_coordinates(
selection_coords["left"], selection_coords["top"],
selection_coords["right"], selection_coords["bottom"]
)
# Redraw the selection highlight to ensure it's visible
self._render_image()
return True
def on_right_click(self, event, get_default_params: Callable) -> bool:
"""Handle right-click to show context menu for manipulation options.
Args:
event: The mouse event
get_default_params: Function to get default parameters for a manipulation
Returns:
True if the right-click was handled, False otherwise
"""
if not self._selectionArea or not self._selectionArea.is_active:
return False
if not self._selectionArea.is_valid():
return False
# Create context menu with all available manipulations
context_menu = tk.Menu(self._root, tearoff=0)
# Add crop to selection option first (most common use case)
context_menu.add_command(label="Crop to Selection", command=self.crop_to_selection)
context_menu.add_separator()
# Add manipulation options
manipulations = GetImageManipulationList()
for manipulation in manipulations:
manipulation_name = manipulation.getManipulationName()
context_menu.add_command(
label=f"Apply {manipulation_name} to Selection",
command=lambda m=manipulation: self.apply_manipulation_to_selection(m, get_default_params)
)
context_menu.add_separator()
context_menu.add_command(label="Clear Selection", command=self.clear_selection)
# Show context menu at cursor position
try:
context_menu.tk_popup(event.x_root, event.y_root)
finally:
context_menu.grab_release()
return True
def _convert_to_image_coordinates(self, start_x, start_y, end_x, end_y):
"""Convert screen coordinates to image coordinates."""
current_image = self._get_current_image()
if current_image is None:
return None
pil_image = current_image.getImage()
if pil_image is None:
return None
# Convert widget coordinates to canvas coordinates (accounts for borders/scroll)
canvas_x1 = self._canvas.canvasx(start_x)
canvas_y1 = self._canvas.canvasy(start_y)
canvas_x2 = self._canvas.canvasx(end_x)
canvas_y2 = self._canvas.canvasy(end_y)
# Get image dimensions
img_width, img_height = pil_image.size
# Get canvas dimensions for scaling
canvas_width, canvas_height = self._get_canvas_dimensions()
if canvas_width <= 0 or canvas_height <= 0:
return None
# Calculate scaling factors to convert canvas coordinates to image coordinates
scale_x = img_width / canvas_width
scale_y = img_height / canvas_height
# Convert canvas coordinates to image coordinates
left = int(min(canvas_x1, canvas_x2) * scale_x)
top = int(min(canvas_y1, canvas_y2) * scale_y)
right = int(max(canvas_x1, canvas_x2) * scale_x)
bottom = int(max(canvas_y1, canvas_y2) * scale_y)
# Ensure coordinates are within image bounds
left = max(0, min(left, img_width))
top = max(0, min(top, img_height))
right = max(left, min(right, img_width))
bottom = max(top, min(bottom, img_height))
# Only return valid selection coordinates
if right > left and bottom > top:
return {"left": left, "top": top, "right": right, "bottom": bottom}
return None
def crop_to_selection(self) -> None:
"""Crop the entire image to the selected area."""
current_image = self._get_current_image()
if current_image is None or not self._selectionArea or not self._selectionArea.is_active:
return
# Get the selection coordinates
coords = self._selectionArea.get_coordinates()
if not coords:
return
left, top, right, bottom = coords
# Validate coordinates
if right <= left or bottom <= top:
return
# Get the original image
original_image = current_image.getImage()
if original_image is None:
return
img_width, img_height = original_image.size
# Ensure coordinates are within image bounds
left = max(0, min(left, img_width))
top = max(0, min(top, img_height))
right = max(left, min(right, img_width))
bottom = max(top, min(bottom, img_height))
# Validate final coordinates
if right <= left or bottom <= top:
return
try:
# Take undo snapshot
current_image.snapshot()
# Use SelectionArea's crop_image method which handles all shapes including lasso
cropped_image = self._selectionArea.crop_image(original_image)
if cropped_image is None:
return
# For lasso, we get an image with transparency, so convert to RGB if needed
# Otherwise keep the cropped image as is
if self._selectionArea.shape == "lasso" and cropped_image.mode == "RGBA":
# Create a white background for lasso crops
background = Image.new("RGB", cropped_image.size, (255, 255, 255))
background.paste(cropped_image, (0, 0), cropped_image.split()[3] if cropped_image.mode == "RGBA" else None)
cropped_image = background
# Update the current image
current_image._imageData = cropped_image
# Clear selection before re-rendering
self.clear_selection()
# Re-render the image (without selection)
self._render_image()
except Exception as e:
# Silently handle errors - crop operation failed
pass
def apply_manipulation_to_selection(self, manipulation, get_default_params: Callable) -> None:
"""Apply the specified manipulation to the selected area only."""
current_image = self._get_current_image()
if current_image is None or not self._selectionArea.is_active:
return
# Take undo snapshot
current_image.snapshot()
# Get the original image
original_image = current_image.getImage()
# Crop the selection area
selected_area = self._selectionArea.crop_image(original_image)
if selected_area is None:
return
# Create a temporary ImageContainer for the selected area
temp_container = ImageContainer()
temp_container._imageData = selected_area
# Apply manipulation to the selected area
# Use default parameters if none specified
params = get_default_params(manipulation)
manipulation.manipulateImage(temp_container, params)
# Get the processed selection
processed_selection = temp_container.getImage()
# Apply the processed selection back to the original image
result_image = self._selectionArea.apply_to_image(original_image, processed_selection)
# Update the current image
current_image._imageData = result_image
# Clear selection before re-rendering
self.clear_selection()
# Re-render the image (without selection)
self._render_image()
def clear_selection(self) -> None:
"""Clear the selection rectangle and reset selection state."""
if self._selectionRectangle:
self._canvas.delete(self._selectionRectangle)
self._selectionRectangle = None
if self._lassoLine:
self._canvas.delete(self._lassoLine)
self._lassoLine = None
self._lassoPath = []
self._selectionStartX = None
self._selectionStartY = None
self._selectionEndX = None
self._selectionEndY = None
self._selectionArea.clear()
def draw_selection_highlight(self) -> None:
"""Draw a colored highlight over the selected area."""
if not self._selectionArea.is_active or not self._selectionArea.is_valid():
return
coords = self._selectionArea.get_coordinates()
if not coords:
return
left, top, right, bottom = coords
# Convert image coordinates to canvas coordinates
current_image = self._get_current_image()
if current_image is None:
return
pil_image = current_image.getImage()
if pil_image is None:
return
img_width, img_height = pil_image.size
# Get canvas dimensions for scaling
canvas_width, canvas_height = self._get_canvas_dimensions()
if canvas_width <= 0 or canvas_height <= 0:
return
# Calculate scaling factors
scale_x = canvas_width / img_width
scale_y = canvas_height / img_height
# Convert coordinates
canvas_left = int(left * scale_x)
canvas_top = int(top * scale_y)
canvas_right = int(right * scale_x)
canvas_bottom = int(bottom * scale_y)
# Draw colored edges only
if self._selectionArea.shape == "lasso" and self._selectionArea.lasso_path:
# Convert lasso path from image coordinates to canvas coordinates
canvas_path = [(int(x * scale_x), int(y * scale_y)) for x, y in self._selectionArea.lasso_path]
if len(canvas_path) >= 3:
# Close the path by adding the first point at the end
closed_path = canvas_path + [canvas_path[0]]
self._canvas.create_line(
*[coord for point in closed_path for coord in point],
fill="red", width=3, smooth=False
)
elif self._selectionArea.shape == "circle":
self._canvas.create_oval(
canvas_left, canvas_top, canvas_right, canvas_bottom,
outline="red", width=3, fill=""
)
else:
self._canvas.create_rectangle(
canvas_left, canvas_top, canvas_right, canvas_bottom,
outline="red", width=3, fill=""
)
@property
def selection_mode(self) -> bool:
"""Get the current selection mode state."""
return self._selectionMode
@property
def selection_type(self) -> str:
"""Get the current selection type."""
return self._selectionType
@property
def selection_area(self) -> SelectionArea:
"""Get the SelectionArea instance."""
return self._selectionArea

295
src/utils/brush.py Normal file
View File

@@ -0,0 +1,295 @@
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
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_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(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 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

134
src/utils/text_entry.py Normal file
View File

@@ -0,0 +1,134 @@
import tkinter as tk
from tkinter import colorchooser, messagebox
from typing import Optional, Dict
class TextEntryHandler:
"""Handler for text input dialog functionality in the GUI.
This class encapsulates the text input dialog that allows users
to enter text and configure its parameters (position, font size, color).
"""
def __init__(self, root_window: tk.Tk):
"""Initialize the text entry handler.
Args:
root_window: The root tkinter window for creating dialogs
"""
self._root = root_window
def show_text_input_dialog(self) -> Optional[Dict[str, any]]:
"""Show a dialog window for text input and parameters.
Returns:
A dictionary with text parameters if user confirmed, None if cancelled.
Dictionary contains: text, x, y, font_size, color
"""
dialog = tk.Toplevel(self._root)
dialog.title("Add Text to Image")
dialog.geometry("400x350")
dialog.resizable(False, False)
dialog.transient(self._root)
dialog.grab_set() # Make dialog modal
# Center the dialog
dialog.update_idletasks()
x = (dialog.winfo_screenwidth() // 2) - (dialog.winfo_width() // 2)
y = (dialog.winfo_screenheight() // 2) - (dialog.winfo_height() // 2)
dialog.geometry(f"+{x}+{y}")
result = {"text": "", "x": 10, "y": 10, "font_size": 40, "color": (0, 0, 0)}
dialog_closed = [False] # Use list to allow modification in nested functions
# Text input field
tk.Label(dialog, text="Text:", font=("Arial", 10)).pack(pady=(20, 5), padx=20, anchor="w")
text_entry = tk.Text(dialog, height=3, width=40, wrap=tk.WORD, font=("Arial", 10))
text_entry.pack(padx=20, pady=(0, 10), fill="x")
text_entry.focus()
# Position and size frame
options_frame = tk.Frame(dialog)
options_frame.pack(padx=20, pady=10, fill="x")
# X position
x_frame = tk.Frame(options_frame)
x_frame.pack(fill="x", pady=5)
tk.Label(x_frame, text="X Position:", width=12, anchor="w").pack(side="left")
x_entry = tk.Entry(x_frame, width=10)
x_entry.pack(side="left", padx=5)
x_entry.insert(0, "10")
# Y position
y_frame = tk.Frame(options_frame)
y_frame.pack(fill="x", pady=5)
tk.Label(y_frame, text="Y Position:", width=12, anchor="w").pack(side="left")
y_entry = tk.Entry(y_frame, width=10)
y_entry.pack(side="left", padx=5)
y_entry.insert(0, "10")
# Font size
size_frame = tk.Frame(options_frame)
size_frame.pack(fill="x", pady=5)
tk.Label(size_frame, text="Font Size:", width=12, anchor="w").pack(side="left")
size_entry = tk.Entry(size_frame, width=10)
size_entry.pack(side="left", padx=5)
size_entry.insert(0, "40")
# Color selection
color_frame = tk.Frame(options_frame)
color_frame.pack(fill="x", pady=5)
tk.Label(color_frame, text="Color:", width=12, anchor="w").pack(side="left")
color_preview = tk.Label(color_frame, bg="#000000", width=8, height=1, relief="sunken", bd=2)
color_preview.pack(side="left", padx=5)
selected_color = [(0, 0, 0)] # Use list to allow modification
def choose_color():
color = colorchooser.askcolor(title="Choose Text Color", color="#000000")
if color[1]: # color[1] is the hex string
selected_color[0] = tuple(int(color[1][i:i+2], 16) for i in (1, 3, 5))
color_preview.config(bg=color[1])
tk.Button(color_frame, text="Choose Color", command=choose_color, width=12).pack(side="left", padx=5)
# Buttons
button_frame = tk.Frame(dialog)
button_frame.pack(pady=20)
def on_ok():
text = text_entry.get("1.0", tk.END).strip()
if not text:
messagebox.showwarning("No Text", "Please enter some text.")
return
try:
result["text"] = text
result["x"] = int(x_entry.get() or "10")
result["y"] = int(y_entry.get() or "10")
result["font_size"] = int(size_entry.get() or "40")
result["color"] = selected_color[0]
dialog_closed[0] = True
dialog.destroy()
except ValueError:
messagebox.showerror("Invalid Input", "Please enter valid numbers for position and font size.")
def on_cancel():
dialog_closed[0] = True
dialog.destroy()
tk.Button(button_frame, text="OK", command=on_ok, width=10).pack(side="left", padx=5)
tk.Button(button_frame, text="Cancel", command=on_cancel, width=10).pack(side="left", padx=5)
# Handle Enter key (Ctrl+Enter for Text widget, Enter for dialog)
text_entry.bind("<Control-Return>", lambda e: on_ok())
dialog.bind("<Return>", lambda e: on_ok())
dialog.bind("<Escape>", lambda e: on_cancel())
# Wait for dialog to close
dialog.wait_window()
# Return None if cancelled, otherwise return result
if not dialog_closed[0] or not result["text"]:
return None
return result