From 3916513cfdfe03824b44efdb8babf8e389c33710 Mon Sep 17 00:00:00 2001 From: vb Date: Thu, 6 Nov 2025 09:44:34 +0100 Subject: [PATCH] moving area selection, text input, and brush functionalities out of GUI.py and into their own files in the utils directory --- src/GUI.py | 820 ++++-------------------------------- src/utils/area_selection.py | 523 +++++++++++++++++++++++ src/utils/brush.py | 295 +++++++++++++ src/utils/text_entry.py | 134 ++++++ 4 files changed, 1033 insertions(+), 739 deletions(-) create mode 100644 src/utils/area_selection.py create mode 100644 src/utils/brush.py create mode 100644 src/utils/text_entry.py diff --git a/src/GUI.py b/src/GUI.py index 292107f..3ba6493 100644 --- a/src/GUI.py +++ b/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("", self._onMouseClick) self._imageCanvas.bind("", self._onMouseDrag) self._imageCanvas.bind("", 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('', lambda event: self._undo()) @@ -202,7 +177,7 @@ class GUI: root.bind_all('', lambda event: root.quit()) root.bind_all('', lambda event: self._toggleSelectionMode()) root.bind_all('', lambda event: self._toggleSelectionShape()) - root.bind_all('', lambda event: self._toggleBrushMode()) + root.bind_all('', 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("", lambda e: on_ok()) - dialog.bind("", lambda e: on_ok()) - dialog.bind("", 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() diff --git a/src/utils/area_selection.py b/src/utils/area_selection.py new file mode 100644 index 0000000..78deaee --- /dev/null +++ b/src/utils/area_selection.py @@ -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 + diff --git a/src/utils/brush.py b/src/utils/brush.py new file mode 100644 index 0000000..1224a5a --- /dev/null +++ b/src/utils/brush.py @@ -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 + diff --git a/src/utils/text_entry.py b/src/utils/text_entry.py new file mode 100644 index 0000000..ba3dda5 --- /dev/null +++ b/src/utils/text_entry.py @@ -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("", lambda e: on_ok()) + dialog.bind("", lambda e: on_ok()) + dialog.bind("", 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 +