From 3b686966aae1741cd6d6bd19f28e3ec6fc0ad6ab Mon Sep 17 00:00:00 2001 From: vb Date: Sun, 9 Nov 2025 19:23:22 +0100 Subject: [PATCH] removing useless options from selection right-click menu; removing test menu; putting options in their correct menus; adding options for shapes menu --- README.md | 4 +- src/GUI.py | 286 ++++++++++++++++++++-- src/ImageManipulation/DrawShape.py | 110 +++++++++ src/ImageManipulation/OutlineSelection.py | 119 +++++++++ src/utils/area_selection.py | 79 ++++-- src/utils/brush.py | 134 ++++++++-- 6 files changed, 672 insertions(+), 60 deletions(-) create mode 100644 src/ImageManipulation/DrawShape.py create mode 100644 src/ImageManipulation/OutlineSelection.py diff --git a/README.md b/README.md index 76ba8e9..7fca09c 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,8 @@ Filters - [x] Histogram thresholding  Shapes menu -- [ ] List of Shapes -- [ ] Outline color +- [x] List of Shapes +- [x] Outline color - [x] Fill color Colors menu diff --git a/src/GUI.py b/src/GUI.py index 37eb518..752e627 100644 --- a/src/GUI.py +++ b/src/GUI.py @@ -1,12 +1,16 @@ import tkinter as tk -from tkinter import filedialog, colorchooser, messagebox -from PIL import ImageTk, ImageDraw, Image +from tkinter import filedialog, colorchooser, messagebox, simpledialog +from PIL import ImageTk, Image from ImageContainer import ImageContainer from ImageManipulation.ManipulationList import * from ImageManipulation.Filters import GaussianBlur, SobelEdge, BinaryThreshold, HistogramThreshold +from ImageManipulation.Grayscale import Grayscale +from ImageManipulation.HSV import HSV +from ImageManipulation.HueShift import HueShift from ImageManipulation.ZoomImage import ZoomImage from ImageManipulation.FlipImage import FlipImage from ImageManipulation.RotateImage import RotateImage +from ImageManipulation.Padding import Padding from SelectionArea import SelectionArea from utils.area_selection import AreaSelectionHandler from utils.brush import BrushHandler @@ -18,6 +22,8 @@ from ImageManipulation.CopyToClipboard import CopyToClipboard from ImageManipulation.PasteFromClipboard import PasteFromClipboard from ImageManipulation.CutToClipboard import CutToClipboard from ImageManipulation.FillSelection import FillSelection +from ImageManipulation.DrawShape import DrawShape +from ImageManipulation.OutlineSelection import OutlineSelection from functools import partial @@ -92,13 +98,13 @@ class GUI: file_menu.add_separator() file_menu.add_command(label="Exit", accelerator="Ctrl+Q", command=root.quit) - test_menu = tk.Menu(menu, tearoff=0) - menu.add_cascade(label="Test", menu=test_menu) - for manipulation in GetImageManipulationList(): - test_menu.add_command( - label=manipulation.getManipulationName(), - command=partial(self._applyManipulation, manipulation) - ) + #test_menu = tk.Menu(menu, tearoff=0) + #menu.add_cascade(label="Test", menu=test_menu) + #for manipulation in GetImageManipulationList(): + # test_menu.add_command( + # label=manipulation.getManipulationName(), + # command=partial(self._applyManipulation, manipulation) + #) edit_menu = tk.Menu(menu, tearoff=0) menu.add_cascade(label="Edit", menu=edit_menu) @@ -108,7 +114,6 @@ class GUI: edit_menu.add_command(label="Toggle Selection Mode", accelerator="Ctrl+Shift+S", command=lambda: self._toggleSelectionMode()) edit_menu.add_command(label="Toggle Selection Shape (Rect/Circle/Lasso)", accelerator="Ctrl+Shift+C", command=lambda: self._toggleSelectionShape()) edit_menu.add_separator() - edit_menu.add_command(label="Brush Tool", accelerator="Ctrl+B", command=lambda: self._toggleBrushMode()) edit_menu.add_command(label="Magic Scissors", accelerator="Ctrl+M", command=lambda: self._enableMagicScissorsMode()) image_menu = tk.Menu(menu, tearoff=0) @@ -131,12 +136,8 @@ class GUI: image_menu.add_separator() image_menu.add_command(label="Rotate Right 90°", command=lambda: self._rotateRight()) image_menu.add_command(label="Rotate Left 90°", command=lambda: self._rotateLeft()) - filter_menu = tk.Menu(menu, tearoff=0) - menu.add_cascade(label="Filter", menu=filter_menu) - filter_menu.add_command(label="Gaussian Filter", command=lambda: self._applyGaussianFilter()) - filter_menu.add_command(label="Sobel Filter", command=lambda: self._applySobelFilter()) - filter_menu.add_command(label="Binary Filter", command=lambda: self._applyBinaryFilter()) - filter_menu.add_command(label="Histogram Thresholding", command=lambda: self._applyHistogramThreshold()) + image_menu.add_separator() + image_menu.add_command(label="Padding", command=lambda: self._applyPadding()) clipboard_menu = tk.Menu(menu, tearoff=0) menu.add_cascade(label="Clipboard", menu=clipboard_menu) clipboard_menu.add_command(label="Copy Image", accelerator="Ctrl+C", command=lambda: self._copyImage()) @@ -146,11 +147,53 @@ class GUI: menu.add_cascade(label="Tools", menu=tools_menu) tools_menu.add_command(label="Zoom In", accelerator="Ctrl+Plus", command=lambda: self._zoomIn()) tools_menu.add_command(label="Zoom Out", accelerator="Ctrl+Minus", command=lambda: self._zoomOut()) + tools_menu.add_separator() + tools_menu.add_command(label="Brush Tool", accelerator="Ctrl+B", command=lambda: self._toggleBrushMode()) + tools_menu.add_command(label="Eraser", command=lambda: self._toggleEraserMode()) + tools_menu.add_command(label="Color Picker", command=lambda: self._openColorPickerDialog()) + tools_menu.add_command(label="Toggle Brush Shape", command=lambda: self._toggleBrushShape()) + tools_menu.add_separator() + filter_menu = tk.Menu(tools_menu, tearoff=0) + tools_menu.add_cascade(label="Filters", menu=filter_menu) + filter_menu.add_command(label="Gaussian Filter", command=lambda: self._applyGaussianFilter()) + filter_menu.add_command(label="Sobel Filter", command=lambda: self._applySobelFilter()) + filter_menu.add_command(label="Binary Filter", command=lambda: self._applyBinaryFilter()) + filter_menu.add_command(label="Histogram Thresholding", command=lambda: self._applyHistogramThreshold()) + filter_menu.add_separator() + filter_menu.add_command(label="Grayscale", command=lambda: self._applyGrayscaleFilter()) + filter_menu.add_command(label="HSV", command=lambda: self._applyHSVFilter()) + filter_menu.add_command(label="Hue Shift", command=lambda: self._applyHueShiftFilter()) shapes_menu = tk.Menu(menu, tearoff=0) menu.add_cascade(label="Shapes", menu=shapes_menu) + shapes_list_menu = tk.Menu(shapes_menu, tearoff=0) + shapes_menu.add_cascade(label="List of Shapes", menu=shapes_list_menu) + shapes_list_menu.add_command(label="Rectangle", command=lambda: self._startShapeDrawing("rectangle")) + shapes_list_menu.add_command(label="Triangle", command=lambda: self._startShapeDrawing("triangle")) + shapes_list_menu.add_command(label="Circle", command=lambda: self._startShapeDrawing("circle")) + outline_colors_menu = tk.Menu(shapes_menu, tearoff=0) + shapes_menu.add_cascade(label="Outline Colors", menu=outline_colors_menu) + outline_colors_menu.add_command(label="Use Brush Color", command=lambda: self._outlineSelection()) + outline_colors_menu.add_separator() + outline_presets = [ + ("Black", "#000000"), + ("White", "#FFFFFF"), + ("Red", "#FF0000"), + ("Green", "#00FF00"), + ("Blue", "#0000FF"), + ("Yellow", "#FFFF00"), + ] + for name, hex_color in outline_presets: + outline_colors_menu.add_command(label=name, command=lambda c=hex_color: self._outlineSelection(c)) + outline_colors_menu.add_separator() + outline_colors_menu.add_command(label="Custom...", command=lambda: self._chooseCustomOutlineColor()) + + shapes_menu.add_separator() shapes_menu.add_command(label="Fill Selection", command=lambda: self._fillSelection()) + colors_menu = tk.Menu(menu, tearoff=0) menu.add_cascade(label="Colors", menu=colors_menu) + colors_menu.add_command(label="Color Palette", command=lambda: self._openColorPaletteDialog()) + colors_menu.add_command(label="Brush Size", command=lambda: self._openBrushSizeDialog()) @@ -397,6 +440,158 @@ class GUI: if self._areaSelectionHandler: self._areaSelectionHandler.set_polygon_selection() + def _startShapeDrawing(self, shape: str) -> None: + """Prepare the canvas to draw a specific shape.""" + valid_shapes = {"rectangle", "triangle", "circle"} + shape = shape.lower() + if shape not in valid_shapes: + return + + if self._currentImage is None or self._currentImage.getImage() is None: + messagebox.showwarning("No Image", "Please load or create an image first.") + return + + if not self._areaSelectionHandler: + messagebox.showwarning("Unavailable", "Selection tools are not available.") + return + + # Disable brush mode if active to prevent conflicts + if self._brushHandler and self._brushHandler.brush_mode: + self._brushHandler.toggle_brush_mode() + + # Cancel magic scissors if running + if self._areaSelectionHandler.selection_type == "magic_scissors": + self._areaSelectionHandler.cancel_magic_scissors() + + # Set appropriate selection shape + if shape == "circle": + self._areaSelectionHandler.set_circular_selection() + else: + self._areaSelectionHandler.set_rectangular_selection() + + self._areaSelectionHandler.set_pending_operation_callback( + lambda shape_name=shape: self._drawShapeFromSelection(shape_name) + ) + self._updateStatus(f"{shape.title()} tool active. Click and drag on the image to draw the shape.") + + def _drawShapeFromSelection(self, shape: str) -> None: + """Draw the requested shape within the current selection bounds.""" + if not self._areaSelectionHandler: + return + + selection_area = self._areaSelectionHandler.selection_area + if not selection_area or not selection_area.is_valid(): + messagebox.showwarning("No Selection", "Please drag on the image to define the shape bounds.") + return + + coords = selection_area.get_coordinates() + if not coords: + messagebox.showwarning("No Selection", "Please drag on the image to define the shape bounds.") + return + + left, top, right, bottom = coords + if left == right or top == bottom: + messagebox.showwarning("No Area", "The selected area is too small to draw the shape.") + return + + if self._currentImage is None or self._currentImage.getImage() is None: + return + + # Resolve drawing color + brush_color_hex = "#000000" + if self._brushHandler: + brush_color_hex = self._brushHandler.brush_color or brush_color_hex + + manipulation = DrawShape() + params = { + "shape": shape, + "bounds": (left, top, right, bottom), + "color": brush_color_hex, + } + self._applyManipulation(manipulation, params) + + self._areaSelectionHandler.clear_selection() + self._renderCurrentImage() + self._updateStatus(f"{shape.title()} drawn using current brush color.") + + def _outlineSelection(self, color_hex: str | None = None, thickness: int = 3) -> None: + """Draw an outline around the current selection using the specified color.""" + if self._currentImage is None or self._currentImage.getImage() is None: + messagebox.showwarning("No Image", "Please load an image first.") + return + + if not self._areaSelectionHandler or not self._areaSelectionHandler.selection_area: + messagebox.showwarning("No Selection", "Please select an area first.") + return + + selection_area = self._areaSelectionHandler.selection_area + if not selection_area.is_active or not selection_area.is_valid(): + messagebox.showwarning("No Selection", "Please select an area first.") + return + + if color_hex is None: + color_hex = "#000000" + if self._brushHandler: + color_hex = self._brushHandler.brush_color or color_hex + + manipulation = OutlineSelection() + params = { + "color": color_hex, + "selection_area": selection_area, + "thickness": thickness, + } + self._applyManipulation(manipulation, params) + + self._areaSelectionHandler.clear_selection() + self._renderCurrentImage() + self._updateStatus("Outline applied to selection.") + + def _chooseCustomOutlineColor(self) -> None: + """Open a color chooser dialog and apply outline with the selected color.""" + color = colorchooser.askcolor(title="Choose Outline Color") + if color and color[1]: + self._outlineSelection(color[1]) + + def _openColorPaletteDialog(self) -> None: + """Open the color palette dialog via the brush handler.""" + if not self._brushHandler: + messagebox.showwarning("Unavailable", "Brush handler not available.") + return + self._brushHandler.show_color_palette_dialog() + + def _openBrushSizeDialog(self) -> None: + """Open the brush size dialog via the brush handler.""" + if not self._brushHandler: + messagebox.showwarning("Unavailable", "Brush handler not available.") + return + self._brushHandler.show_size_dialog() + + def _toggleEraserMode(self) -> None: + """Toggle the eraser mode via the brush handler.""" + if not self._brushHandler: + messagebox.showwarning("Unavailable", "Brush handler not available.") + return + self._brushHandler.toggle_erase_mode() + status = "Eraser active." if self._brushHandler.erase_mode else "Eraser inactive." + self._updateStatus(status) + + def _openColorPickerDialog(self) -> None: + """Open a color picker dialog to set the brush color.""" + if not self._brushHandler: + messagebox.showwarning("Unavailable", "Brush handler not available.") + return + self._brushHandler.show_color_picker() + self._updateStatus("Brush color updated.") + + def _toggleBrushShape(self) -> None: + """Toggle the current brush shape.""" + if not self._brushHandler: + messagebox.showwarning("Unavailable", "Brush handler not available.") + return + self._brushHandler.toggle_brush_shape() + shape = self._brushHandler.brush_shape.title() + self._updateStatus(f"Brush shape set to {shape}.") + def _onDoubleClick(self, event) -> None: """Handle double-click for polygon completion.""" if self._areaSelectionHandler: @@ -475,10 +670,8 @@ class GUI: manipulation = RotateImage() params = {"angle": 90} # Positive angle for counter-clockwise rotation self._applyManipulation(manipulation, params) - self._updateStatus(f"Selection shape: {self._areaSelectionHandler.selection_type.title()}.") - self._renderCurrentImage() - if was_magic and self._areaSelectionHandler.selection_type != "magic_scissors": - self._updateStatus("Magic Scissors cancelled.") + if self._areaSelectionHandler: + self._updateStatus(f"Selection shape: {self._areaSelectionHandler.selection_type.title()}.") def _onMouseClick(self, event) -> None: """Handle mouse click for selection or brush.""" @@ -641,6 +834,59 @@ class GUI: params = {} # No parameters needed self._applyManipulation(manipulation, params) + def _applyGrayscaleFilter(self) -> None: + """Convert the image (or selection) to grayscale.""" + if self._currentImage is None: + messagebox.showwarning("No Image", "Please load an image first.") + return + + manipulation = Grayscale() + params: dict[str, int] = {} + self._applyManipulation(manipulation, params) + + def _applyPadding(self) -> None: + """Add padding to the image.""" + if self._currentImage is None: + messagebox.showwarning("No Image", "Please load an image first.") + return + + manipulation = Padding() + params: dict[str, int] = {} + self._applyManipulation(manipulation, params) + + def _applyHSVFilter(self) -> None: + """Convert the image (or selection) to HSV color space.""" + if self._currentImage is None: + messagebox.showwarning("No Image", "Please load an image first.") + return + + manipulation = HSV() + params: dict[str, int] = {} + self._applyManipulation(manipulation, params) + + def _applyHueShiftFilter(self) -> None: + """Apply a hue shift to the image (or selection).""" + if self._currentImage is None: + messagebox.showwarning("No Image", "Please load an image first.") + return + + default_params = self._getDefaultParams(HueShift()) + default_hue = default_params.get("hue", 50) + hue_value = simpledialog.askinteger( + "Hue Shift", + "Enter hue shift amount (-180 to 180):", + initialvalue=default_hue, + minvalue=-180, + maxvalue=180, + parent=self._root + ) + if hue_value is None: + return + + manipulation = HueShift() + params = {"hue": hue_value} + self._applyManipulation(manipulation, params) + def _zoomIn(self) -> None: """Zoom in the image by a factor of 1.2.""" if self._currentImage is None: diff --git a/src/ImageManipulation/DrawShape.py b/src/ImageManipulation/DrawShape.py new file mode 100644 index 0000000..03e5042 --- /dev/null +++ b/src/ImageManipulation/DrawShape.py @@ -0,0 +1,110 @@ +from typing import Any, Dict, List, Optional, Tuple + +from PIL import ImageDraw + +from .ImageManipulation import ImageManipulation +from ImageContainer import ImageContainer +from SelectionArea import SelectionArea + + +class DrawShape(ImageManipulation): + """Manipulation for drawing basic filled shapes inside a selection.""" + + _SUPPORTED_SHAPES = {"rectangle", "triangle", "circle"} + + def getManipulationName(self) -> str: + return "Draw Shape" + + def getParameters(self) -> List[str]: + return ["shape", "bounds", "color", "selection_area"] + + def manipulateImage(self, image: ImageContainer, parameters: Any) -> None: + if image is None or image.getImage() is None: + return + + params = self._ensure_dict(parameters) + if params is None: + return + + shape = params.get("shape", "") + if not isinstance(shape, str): + return + shape = shape.lower() + if shape not in self._SUPPORTED_SHAPES: + return + + bounds = self._extract_bounds(params) + if bounds is None: + return + left, top, right, bottom = bounds + if right <= left or bottom <= top: + return + + rgb_color = self._normalize_color(params.get("color")) + if rgb_color is None: + return + + pil_image = image.getImage() + draw = ImageDraw.Draw(pil_image) + + if shape == "rectangle": + draw.rectangle([left, top, right, bottom], fill=rgb_color, outline=rgb_color) + elif shape == "circle": + draw.ellipse([left, top, right, bottom], fill=rgb_color, outline=rgb_color) + elif shape == "triangle": + mid_x = (left + right) / 2 + points = [(mid_x, top), (left, bottom), (right, bottom)] + draw.polygon(points, fill=rgb_color, outline=rgb_color) + + image._imageData = pil_image + + # Helpers ----------------------------------------------------------------- + + @staticmethod + def _ensure_dict(parameters: Any) -> Optional[Dict[str, Any]]: + if isinstance(parameters, dict): + return parameters + return None + + @staticmethod + def _extract_bounds(params: Dict[str, Any]) -> Optional[Tuple[int, int, int, int]]: + bounds = params.get("bounds") + if isinstance(bounds, (list, tuple)) and len(bounds) == 4: + try: + left, top, right, bottom = (int(bounds[0]), int(bounds[1]), int(bounds[2]), int(bounds[3])) + return left, top, right, bottom + except (TypeError, ValueError): + return None + + selection_area = params.get("selection_area") + if isinstance(selection_area, SelectionArea): + coords = selection_area.get_coordinates() + if coords: + return coords + return None + + @staticmethod + def _normalize_color(color: Any) -> Optional[Tuple[int, int, int]]: + if color is None: + return (0, 0, 0) + + if isinstance(color, str): + hex_color = color.lstrip("#") + if len(hex_color) == 3: + hex_color = "".join(ch * 2 for ch in hex_color) + if len(hex_color) != 6: + return None + try: + return tuple(int(hex_color[i:i + 2], 16) for i in (0, 2, 4)) + except ValueError: + return None + + if isinstance(color, (tuple, list)) and len(color) >= 3: + try: + return tuple(int(max(0, min(255, c))) for c in color[:3]) + except (TypeError, ValueError): + return None + + return None + + diff --git a/src/ImageManipulation/OutlineSelection.py b/src/ImageManipulation/OutlineSelection.py new file mode 100644 index 0000000..9a3e012 --- /dev/null +++ b/src/ImageManipulation/OutlineSelection.py @@ -0,0 +1,119 @@ +from typing import Any, Dict, List, Optional, Sequence, Tuple + +from PIL import ImageDraw + +from .ImageManipulation import ImageManipulation +from ImageContainer import ImageContainer +from SelectionArea import SelectionArea + + +class OutlineSelection(ImageManipulation): + """Draw an outline around the current selection using the specified color.""" + + def getManipulationName(self) -> str: + return "Outline Selection" + + def getParameters(self) -> List[str]: + return ["color", "selection_area", "thickness"] + + def manipulateImage(self, image: ImageContainer, parameters: Any) -> None: + if image is None or image.getImage() is None: + return + + params = self._ensure_dict(parameters) + if params is None: + return + + selection_area = params.get("selection_area") + if not isinstance(selection_area, SelectionArea): + return + if not selection_area.is_active or not selection_area.is_valid(): + return + + color = self._normalize_color(params.get("color")) + if color is None: + return + + thickness = self._sanitize_thickness(params.get("thickness")) + + coords = selection_area.get_coordinates() + if not coords: + return + left, top, right, bottom = coords + + if right <= left or bottom <= top: + return + + pil_image = image.getImage() + draw = ImageDraw.Draw(pil_image) + + shape = selection_area.shape + if shape == "circle": + draw.ellipse([left, top, right, bottom], outline=color, width=thickness) + elif shape == "lasso": + path = self._sanitize_path(selection_area.lasso_path) + if len(path) >= 2: + draw.line(path + [path[0]], fill=color, width=thickness, joint="curve") + elif shape == "polygon": + path = self._sanitize_path(selection_area.polygon_path) + if len(path) >= 2: + draw.line(path + [path[0]], fill=color, width=thickness, joint="curve") + else: + draw.rectangle([left, top, right, bottom], outline=color, width=thickness) + + image._imageData = pil_image + + # Helpers ----------------------------------------------------------------- + + @staticmethod + def _ensure_dict(parameters: Any) -> Optional[Dict[str, Any]]: + if isinstance(parameters, dict): + return parameters + return None + + @staticmethod + def _normalize_color(color: Any) -> Optional[Tuple[int, int, int]]: + if color is None: + return 0, 0, 0 + + if isinstance(color, str): + hex_color = color.lstrip("#") + if len(hex_color) == 3: + hex_color = "".join(ch * 2 for ch in hex_color) + if len(hex_color) != 6: + return None + try: + return tuple(int(hex_color[i:i + 2], 16) for i in (0, 2, 4)) + except ValueError: + return None + + if isinstance(color, Sequence) and len(color) >= 3: + try: + return tuple(int(max(0, min(255, c))) for c in color[:3]) + except (TypeError, ValueError): + return None + + return None + + @staticmethod + def _sanitize_thickness(value: Any) -> int: + try: + thickness = int(value) + except (TypeError, ValueError): + thickness = 3 + return max(1, min(50, thickness)) + + @staticmethod + def _sanitize_path(path: Any) -> List[Tuple[int, int]]: + if not isinstance(path, Sequence): + return [] + sanitized: List[Tuple[int, int]] = [] + for point in path: + if isinstance(point, Sequence) and len(point) >= 2: + try: + sanitized.append((int(point[0]), int(point[1]))) + except (TypeError, ValueError): + continue + return sanitized + + diff --git a/src/utils/area_selection.py b/src/utils/area_selection.py index 3c44074..d451c11 100644 --- a/src/utils/area_selection.py +++ b/src/utils/area_selection.py @@ -12,7 +12,17 @@ if _parent_dir not in sys.path: from SelectionArea import SelectionArea from ImageContainer import ImageContainer -from ImageManipulation.ManipulationList import GetImageManipulationList +from ImageManipulation.FlipImage import FlipImage +from ImageManipulation.HSV import HSV +from ImageManipulation.HueShift import HueShift +from ImageManipulation.Grayscale import Grayscale +from ImageManipulation.BoxBlur import BoxBlur +from ImageManipulation.Filters import ( + GaussianBlur, + SobelEdge, + BinaryThreshold, + HistogramThreshold, +) from utils.magic_scissors import MagicScissors @@ -181,6 +191,12 @@ class AreaSelectionHandler: self._render_image() return True + def _ensure_selection_outside(self) -> None: + """Ensure the current selection targets the area outside the original bounds.""" + if (self._selectionArea and self._selectionArea.is_active and + self._selectionArea.is_valid() and not self._selectionArea.is_inverted()): + self.toggle_selection_inversion() + def _complete_polygon_selection(self) -> None: """Complete the polygon selection by converting canvas coordinates to image coordinates.""" if not self._polygonPath or len(self._polygonPath) < 3: @@ -493,27 +509,50 @@ class AreaSelectionHandler: if not self._selectionArea.is_valid(): return False - # Create context menu with all available manipulations + # Create simplified context menu with specific options context_menu = tk.Menu(self._root, tearoff=0) - - # Add crop to selection option first (most common use case) + + context_menu.add_command(label="Clear Selection", command=lambda: self.clear_selection()) + context_menu.add_command(label="Select Outside Area", command=self._ensure_selection_outside) + context_menu.add_separator() + 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) - - invert_label = "Select Inside Area" if self._selectionArea.is_inverted() else "Select Outside Area" - context_menu.add_command(label=invert_label, command=self.toggle_selection_inversion) + context_menu.add_command( + label="Flip to Selection", + command=lambda: self.apply_manipulation_to_selection(FlipImage(), get_default_params) + ) + context_menu.add_command( + label="Hue Shift to Selection", + command=lambda: self.apply_manipulation_to_selection(HueShift(), get_default_params) + ) + context_menu.add_command( + label="HSV to Selection", + command=lambda: self.apply_manipulation_to_selection(HSV(), get_default_params) + ) + context_menu.add_command( + label="Grayscale to Selection", + command=lambda: self.apply_manipulation_to_selection(Grayscale(), get_default_params) + ) + context_menu.add_command( + label="Box Blur to Selection", + command=lambda: self.apply_manipulation_to_selection(BoxBlur(), get_default_params) + ) + context_menu.add_command( + label="Gaussian Blur to Selection", + command=lambda: self.apply_manipulation_to_selection(GaussianBlur(), get_default_params) + ) + context_menu.add_command( + label="Sobel Edge to Selection", + command=lambda: self.apply_manipulation_to_selection(SobelEdge(), get_default_params) + ) + context_menu.add_command( + label="Binary Threshold to Selection", + command=lambda: self.apply_manipulation_to_selection(BinaryThreshold(), get_default_params) + ) + context_menu.add_command( + label="Histogram Threshold to Selection", + command=lambda: self.apply_manipulation_to_selection(HistogramThreshold(), get_default_params) + ) # Show context menu at cursor position try: diff --git a/src/utils/brush.py b/src/utils/brush.py index d31687c..1169b7e 100644 --- a/src/utils/brush.py +++ b/src/utils/brush.py @@ -1,5 +1,5 @@ import tkinter as tk -from tkinter import colorchooser +from tkinter import colorchooser, simpledialog from PIL import ImageDraw from typing import Callable, Optional import sys @@ -63,6 +63,9 @@ class BrushHandler: self._eraseToggleButton = None self._fillButton = None self._colorSwatches = [] # List of color swatch buttons + self._shapeVar = None + self._sizeVar = None + self._sizeLabel = None def create_ui_panel(self, parent_frame: tk.Frame) -> None: """Create the brush settings UI panel. @@ -144,25 +147,9 @@ class BrushHandler: palette_frame = tk.Frame(color_section, bg="lightgray") palette_frame.pack(pady=5, fill="x") - # Define color palette - common colors arranged in a grid - color_palette = [ - # Row 1: Basic colors - ["#000000", "#FFFFFF", "#FF0000", "#00FF00", "#0000FF", "#FFFF00"], - # Row 2: Secondary colors - ["#FF00FF", "#00FFFF", "#FFA500", "#800080", "#FFC0CB", "#A52A2A"], - # Row 3: Grays and browns - ["#808080", "#C0C0C0", "#D3D3D3", "#8B4513", "#654321", "#DEB887"], - # Row 4: Blues and greens - ["#000080", "#008080", "#008000", "#00CED1", "#4682B4", "#32CD32"], - # Row 5: Reds and oranges - ["#DC143C", "#FF4500", "#FF6347", "#FF1493", "#8B0000", "#CD5C5C"], - # Row 6: Yellows and purples - ["#FFD700", "#FFA500", "#DA70D6", "#9370DB", "#4B0082", "#9932CC"] - ] - # Create color swatches self._colorSwatches = [] - for row_idx, row_colors in enumerate(color_palette): + for row_idx, row_colors in enumerate(self._get_color_palette()): row_frame = tk.Frame(palette_frame, bg="lightgray") row_frame.pack(pady=2) row_swatches = [] @@ -538,3 +525,114 @@ class BrushHandler: """Get the current brush color.""" return self._brushColor + @property + def brush_shape(self) -> str: + """Get the current brush shape.""" + return self._brushShape + + @property + def erase_mode(self) -> bool: + """Get the current erase mode state.""" + return self._eraseMode + + # ------------------------------------------------------------------ menus + + def show_color_palette_dialog(self) -> None: + """Display a color palette dialog for quick color selection.""" + dialog = tk.Toplevel(self._root) + dialog.title("Brush Color Palette") + dialog.resizable(False, False) + dialog.transient(self._root) + dialog.grab_set() + + palette_frame = tk.Frame(dialog, bg="lightgray", padx=15, pady=15) + palette_frame.pack(fill="both", expand=True) + + for row_colors in self._get_color_palette(): + row_frame = tk.Frame(palette_frame, bg="lightgray") + row_frame.pack(pady=2) + for color in row_colors: + tk.Button( + row_frame, + bg=color, + activebackground=color, + width=4, + relief="raised", + bd=2, + command=lambda c=color: self._handle_palette_selection(dialog, c) + ).pack(side="left", padx=2, pady=2) + + tk.Button( + palette_frame, + text="Custom Color...", + command=lambda: self._handle_custom_color(dialog), + width=18 + ).pack(pady=(10, 0)) + + dialog.wait_window() + + def show_size_dialog(self) -> None: + """Prompt the user to set the brush size.""" + size = simpledialog.askinteger( + "Brush Size", + "Enter brush size (1-50):", + parent=self._root, + minvalue=1, + maxvalue=50, + initialvalue=self._brushSize + ) + if size is not None: + self.set_brush_size(size) + + def set_brush_size(self, size: int) -> None: + """Set the brush size programmatically and update UI.""" + size = max(1, min(50, int(size))) + self._brushSize = size + if self._sizeVar is not None: + self._sizeVar.set(size) + if self._sizeLabel is not None: + self._sizeLabel.config(text=str(size)) + + def set_brush_color(self, color: str) -> None: + """Set the brush color programmatically.""" + if color: + self._select_color(color) + + def show_color_picker(self) -> None: + """Open a standard color chooser dialog for brush color.""" + color = colorchooser.askcolor(title="Choose Brush Color", color=self._brushColor) + if color and color[1]: + self._select_color(color[1]) + + def toggle_brush_shape(self) -> None: + """Toggle between circular and rectangular brush shapes.""" + next_shape = "rectangular" if self._brushShape == "circular" else "circular" + self._brushShape = next_shape + if self._shapeVar is not None: + self._shapeVar.set(next_shape) + self._update_brush_shape() + + # ------------------------------------------------------------------ helpers + + def _handle_palette_selection(self, dialog: tk.Toplevel, color: str) -> None: + self._select_color(color) + dialog.destroy() + + def _handle_custom_color(self, dialog: tk.Toplevel) -> None: + color = colorchooser.askcolor(title="Choose Custom Color", color=self._brushColor) + if color and color[1]: + self._select_color(color[1]) + dialog.destroy() + + @staticmethod + def _get_color_palette(): + """Return the default color palette layout.""" + return [ + ["#000000", "#FFFFFF", "#FF0000", "#00FF00", "#0000FF", "#FFFF00"], + ["#FF00FF", "#00FFFF", "#FFA500", "#800080", "#FFC0CB", "#A52A2A"], + ["#808080", "#C0C0C0", "#D3D3D3", "#8B4513", "#654321", "#DEB887"], + ["#000080", "#008080", "#008000", "#00CED1", "#4682B4", "#32CD32"], + ["#DC143C", "#FF4500", "#FF6347", "#FF1493", "#8B0000", "#CD5C5C"], + ["#FFD700", "#FFA500", "#DA70D6", "#9370DB", "#4B0082", "#9932CC"], + ] +