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:
820
src/GUI.py
820
src/GUI.py
@@ -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
523
src/utils/area_selection.py
Normal 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
295
src/utils/brush.py
Normal 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
134
src/utils/text_entry.py
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user