diff --git a/README.md b/README.md index 80f7cbf..769f8f3 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,11 @@ ### Minimum Features File menu -- [ ] New +- [x] New - [x] Open -- [ ] Save +- [x] Save - [x] Save as -- [ ] Properties +- [x] Properties - [x] Quit Clipboard menu diff --git a/lena copy.png b/lena copy.png new file mode 100644 index 0000000..59ef68a Binary files /dev/null and b/lena copy.png differ diff --git a/lena.png b/lena.png index 59ef68a..8937a4e 100644 Binary files a/lena.png and b/lena.png differ diff --git a/src/GUI.py b/src/GUI.py index 3ba6493..68d2e4c 100644 --- a/src/GUI.py +++ b/src/GUI.py @@ -1,5 +1,5 @@ import tkinter as tk -from tkinter import filedialog, messagebox, colorchooser, simpledialog +from tkinter import filedialog, colorchooser from PIL import ImageTk, ImageDraw, Image from ImageContainer import ImageContainer from ImageManipulation.ManipulationList import * @@ -7,6 +7,8 @@ from SelectionArea import SelectionArea from utils.area_selection import AreaSelectionHandler from utils.brush import BrushHandler from utils.text_entry import TextEntryHandler +from utils.file_operations import FileOperationsHandler +from utils.image_properties import ImagePropertiesHandler from functools import partial @@ -31,6 +33,12 @@ class GUI: # Text entry handler (will be initialized in initialise()) _textEntryHandler = None + # File operations handler (will be initialized in initialise()) + _fileOperationsHandler = None + + # Image properties handler (will be initialized in initialise()) + _imagePropertiesHandler = None + _canvasImageWidth = None # Store canvas image dimensions _canvasImageHeight = None @@ -61,9 +69,15 @@ class GUI: root.config(menu=menu) file_menu = tk.Menu(menu, tearoff=0) menu.add_cascade(label="File", menu=file_menu) - file_menu.add_command(label="Open Image", accelerator="Ctrl+O", command=lambda: self._openImage()) + file_menu.add_command(label="New", accelerator="Ctrl+N", command=lambda: self._newImage()) + file_menu.add_command(label="Open", accelerator="Ctrl+O", command=lambda: self._openImage()) file_menu.add_command(label="Open Camera", accelerator="Ctrl+K", command=lambda: self._openCamera()) - file_menu.add_command(label="Save Image", accelerator="Ctrl+S", command=lambda: self._saveImage()) + file_menu.add_separator() + file_menu.add_command(label="Save", accelerator="Ctrl+S", command=lambda: self._saveImage()) + file_menu.add_command(label="Save As", command=lambda: self._saveImageAs()) + file_menu.add_separator() + file_menu.add_command(label="Properties", command=lambda: self._showProperties()) + file_menu.add_separator() file_menu.add_command(label="Exit", accelerator="Ctrl+Q", command=root.quit) test_menu = tk.Menu(menu, tearoff=0) @@ -167,10 +181,26 @@ class GUI: # Text entry handler from utils/text_entry.py self._textEntryHandler = TextEntryHandler(root) + + # File operations handler from utils/file_operations.py + def set_current_image(image): + self._currentImage = image + + self._fileOperationsHandler = FileOperationsHandler( + get_current_image=get_current_image, + set_current_image=set_current_image, + render_image=self._renderCurrentImage + ) + + # Image properties handler from utils/image_properties.py + self._imagePropertiesHandler = ImagePropertiesHandler( + get_current_image=get_current_image + ) # Key bindings root.bind_all('', lambda event: self._undo()) root.bind_all('', lambda event: self._redo()) + root.bind_all('', lambda event: self._newImage()) root.bind_all('', lambda event: self._openImage()) root.bind_all('', lambda event: self._saveImage()) root.bind_all('', lambda event: self._openCamera()) @@ -181,6 +211,11 @@ class GUI: root.mainloop() + def _newImage(self) -> None: + """Create a new blank image with user-specified dimensions.""" + if self._fileOperationsHandler: + self._fileOperationsHandler.new_image() + def _openImage(self) -> None: file_path = filedialog.askopenfilename( filetypes=[("Image files", "*.jpg *.jpeg *.png *.gif *.bmp")] @@ -240,14 +275,14 @@ class GUI: self._renderCurrentImage() def _saveImage(self) -> None: - if self._currentImage is None or self._currentImage.getImage() is None: - return - path = filedialog.asksaveasfilename(defaultextension=".png", filetypes=[ - ("PNG", "*.png"), ("JPEG", "*.jpg;*.jpeg"), ("Bitmap", "*.bmp"), ("All Files", "*.*") - ]) - if not path: - return - self._currentImage.saveImage(path) + """Save the current image without asking for a name (uses current path).""" + if self._fileOperationsHandler: + self._fileOperationsHandler.save_image() + + def _saveImageAs(self) -> None: + """Save the current image with a new name (prompts for filename).""" + if self._fileOperationsHandler: + self._fileOperationsHandler.save_image_as() def _undo(self) -> None: if self._currentImage is None: @@ -336,13 +371,15 @@ class GUI: else: return {} - - def _toggleBrushMode(self) -> None: """Toggle brush mode on/off.""" if self._brushHandler: self._brushHandler.toggle_brush_mode() + def _showProperties(self) -> None: + """Display image properties in a dialog.""" + if self._imagePropertiesHandler: + self._imagePropertiesHandler.show_properties() def _openCamera(self): camera = CameraWindow() diff --git a/src/ImageContainer.py b/src/ImageContainer.py index 46b9d43..8608aa4 100644 --- a/src/ImageContainer.py +++ b/src/ImageContainer.py @@ -33,9 +33,31 @@ class ImageContainer: def getImage(self) -> Image or None: return self._imageData + def getPath(self) -> str or None: + """Get the current file path of the image.""" + return self._path + + def setPath(self, path: str) -> None: + """Set the file path of the image.""" + self._path = path + + def loadBlankImage(self, width: int = 800, height: int = 600) -> None: + """Load a blank white image with specified dimensions. + + :param width: Width of the blank image (default: 800) + :param height: Height of the blank image (default: 600) + """ + self._imageData = Image.new('RGB', (width, height), color='white') + self._path = None # Blank image has no file path initially + self._history = [] + self._redo_history = [] + print("Created blank image:", width, "x", height) + def saveImage(self, path: str) -> None: if self._imageData is not None: self._imageData.save(path) + # Update the path after saving + self._path = path def snapshot(self) -> None: """Push a copy of current image to history for undo.""" diff --git a/src/utils/brush.py b/src/utils/brush.py index 1224a5a..504860f 100644 --- a/src/utils/brush.py +++ b/src/utils/brush.py @@ -54,6 +54,7 @@ class BrushHandler: # UI element references (will be set by GUI) self._colorPreview = None self._brushStatusLabel = None + self._colorSwatches = [] # List of color swatch buttons def create_ui_panel(self, parent_frame: tk.Frame) -> None: """Create the brush settings UI panel. @@ -70,20 +71,68 @@ class BrushHandler: 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) + preview_frame = tk.Frame(color_section, bg="lightgray") + preview_frame.pack(pady=5, fill="x") - def choose_color(): - color = colorchooser.askcolor(title="Choose Brush Color", color=self._brushColor) + self._colorPreview = tk.Label(preview_frame, bg=self._brushColor, width=20, height=2, relief="sunken", bd=2) + self._colorPreview.pack(pady=5) + + # Color palette + palette_frame = tk.Frame(color_section, bg="lightgray") + palette_frame.pack(pady=5, fill="x") + + # Define color palette - common colors arranged in a grid + color_palette = [ + # Row 1: Basic colors + ["#000000", "#FFFFFF", "#FF0000", "#00FF00", "#0000FF", "#FFFF00"], + # Row 2: Secondary colors + ["#FF00FF", "#00FFFF", "#FFA500", "#800080", "#FFC0CB", "#A52A2A"], + # Row 3: Grays and browns + ["#808080", "#C0C0C0", "#D3D3D3", "#8B4513", "#654321", "#DEB887"], + # Row 4: Blues and greens + ["#000080", "#008080", "#008000", "#00CED1", "#4682B4", "#32CD32"], + # Row 5: Reds and oranges + ["#DC143C", "#FF4500", "#FF6347", "#FF1493", "#8B0000", "#CD5C5C"], + # Row 6: Yellows and purples + ["#FFD700", "#FFA500", "#DA70D6", "#9370DB", "#4B0082", "#9932CC"] + ] + + # Create color swatches + self._colorSwatches = [] + for row_idx, row_colors in enumerate(color_palette): + row_frame = tk.Frame(palette_frame, bg="lightgray") + row_frame.pack(pady=2) + row_swatches = [] + for col_idx, color in enumerate(row_colors): + swatch = tk.Button( + row_frame, + bg=color, + activebackground=color, + width=3, + height=1, + relief="raised", + bd=2, + cursor="hand2", + command=lambda c=color: self._select_color(c) + ) + swatch.pack(side="left", padx=1, pady=1) + row_swatches.append(swatch) + self._colorSwatches.append(row_swatches) + + # Custom color button (still allow custom color selection) + custom_frame = tk.Frame(color_section, bg="lightgray") + custom_frame.pack(pady=5, fill="x") + + def choose_custom_color(): + color = colorchooser.askcolor(title="Choose Custom Color", color=self._brushColor) if color[1]: # color[1] is the hex string - self._brushColor = color[1] - self._colorPreview.config(bg=self._brushColor) + self._select_color(color[1]) - tk.Button(color_frame, text="Choose Color", command=choose_color, width=12).pack(side="left", padx=5) + tk.Button(custom_frame, text="Custom Color...", command=choose_custom_color, width=15).pack(pady=2) + + # Initialize color selection highlighting + self._select_color(self._brushColor) # Size selection section size_section = tk.Frame(parent_frame, bg="lightgray") @@ -116,6 +165,29 @@ class BrushHandler: self._brushStatusLabel = tk.Label(info_frame, text="Inactive", fg="red", bg="lightgray", font=("Arial", 9)) self._brushStatusLabel.pack(anchor="w", pady=5) + def _select_color(self, color: str) -> None: + """Select a color from the palette or custom color. + + Args: + color: Hex color string (e.g., "#FF0000") + """ + self._brushColor = color + if self._colorPreview: + self._colorPreview.config(bg=self._brushColor) + + # Update visual feedback on swatches (highlight selected if in palette) + color_found = False + for row_swatches in self._colorSwatches: + for swatch in row_swatches: + swatch_color = swatch.cget("bg") + if swatch_color.upper() == color.upper(): + swatch.config(relief="sunken", bd=3) + color_found = True + else: + swatch.config(relief="raised", bd=2) + + # If custom color not in palette, all swatches remain raised (normal state) + def toggle_brush_mode(self) -> None: """Toggle brush mode on/off.""" self._brushMode = not self._brushMode diff --git a/src/utils/file_operations.py b/src/utils/file_operations.py new file mode 100644 index 0000000..1a63b07 --- /dev/null +++ b/src/utils/file_operations.py @@ -0,0 +1,106 @@ +import tkinter as tk +from tkinter import filedialog, messagebox, simpledialog +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) + +from ImageContainer import ImageContainer + + +class FileOperationsHandler: + """Handler for file operations: New, Save, and Save As. + + This class encapsulates file operations including creating new images, + saving images, and managing file paths. + """ + + def __init__(self, + get_current_image: Callable, + set_current_image: Callable, + render_image: Callable): + """Initialize the file operations handler. + + Args: + get_current_image: Callable that returns the current ImageContainer + set_current_image: Callable that sets the current ImageContainer + render_image: Callable that renders the current image to the canvas + """ + self._get_current_image = get_current_image + self._set_current_image = set_current_image + self._render_image = render_image + + def new_image(self) -> None: + """Create a new blank image with user-specified dimensions.""" + # Prompt for width + width = simpledialog.askinteger( + "New Image", + "Enter image width (pixels):", + initialvalue=800, + minvalue=1, + maxvalue=10000 + ) + if width is None: # User cancelled + return + + # Prompt for height + height = simpledialog.askinteger( + "New Image", + "Enter image height (pixels):", + initialvalue=600, + minvalue=1, + maxvalue=10000 + ) + if height is None: # User cancelled + return + + # Create the blank image with specified dimensions + new_image = ImageContainer() + new_image.loadBlankImage(width, height) + self._set_current_image(new_image) + self._render_image() + + def save_image(self) -> None: + """Save the current image without asking for a name (uses current path).""" + current_image = self._get_current_image() + if current_image is None or current_image.getImage() is None: + messagebox.showwarning("No Image", "No image to save.") + return + + # Get the current path + current_path = current_image.getPath() + + # If no path exists (e.g., new blank image), prompt for save as + if current_path is None: + self.save_image_as() + else: + # Save to the current path + try: + current_image.saveImage(current_path) + messagebox.showinfo("Save", f"Image saved to {current_path}") + except Exception as e: + messagebox.showerror("Save Error", f"Failed to save image: {str(e)}") + + def save_image_as(self) -> None: + """Save the current image with a new name (prompts for filename).""" + current_image = self._get_current_image() + if current_image is None or current_image.getImage() is None: + messagebox.showwarning("No Image", "No image to save.") + return + + path = filedialog.asksaveasfilename(defaultextension=".png", filetypes=[ + ("PNG", "*.png"), ("JPEG", "*.jpg;*.jpeg"), ("Bitmap", "*.bmp"), ("All Files", "*.*") + ]) + if not path: + return + + try: + current_image.saveImage(path) + messagebox.showinfo("Save As", f"Image saved to {path}") + except Exception as e: + messagebox.showerror("Save Error", f"Failed to save image: {str(e)}") + diff --git a/src/utils/image_properties.py b/src/utils/image_properties.py new file mode 100644 index 0000000..36d7b83 --- /dev/null +++ b/src/utils/image_properties.py @@ -0,0 +1,64 @@ +import tkinter as tk +from tkinter import messagebox +from typing import 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) + + +class ImagePropertiesHandler: + """Handler for displaying image properties. + + This class encapsulates the functionality to display image information + such as dimensions, mode, format, file size, and path. + """ + + def __init__(self, get_current_image: Callable): + """Initialize the image properties handler. + + Args: + get_current_image: Callable that returns the current ImageContainer + """ + self._get_current_image = get_current_image + + def show_properties(self) -> None: + """Display image properties in a dialog.""" + current_image = self._get_current_image() + if current_image is None or current_image.getImage() is None: + messagebox.showwarning("No Image", "No image loaded.") + return + + image = current_image.getImage() + width, height = current_image.getDimensions() + path = current_image.getPath() + mode = image.mode if image else "N/A" + format_name = image.format if image else "N/A" + + # Get file size if path exists + file_size = "N/A" + if path: + try: + size_bytes = os.path.getsize(path) + if size_bytes < 1024: + file_size = f"{size_bytes} bytes" + elif size_bytes < 1024 * 1024: + file_size = f"{size_bytes / 1024:.2f} KB" + else: + file_size = f"{size_bytes / (1024 * 1024):.2f} MB" + except: + file_size = "Unknown" + + # Create properties message + properties_text = f"Image Properties\n\n" + properties_text += f"Dimensions: {width} x {height} pixels\n" + properties_text += f"Mode: {mode}\n" + properties_text += f"Format: {format_name}\n" + properties_text += f"File Size: {file_size}\n" + properties_text += f"Path: {path if path else 'Unsaved'}" + + messagebox.showinfo("Image Properties", properties_text) +