adding requirements for file menu; improving brush color palette.
This commit is contained in:
63
src/GUI.py
63
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('<Control-z>', lambda event: self._undo())
|
||||
root.bind_all('<Control-y>', lambda event: self._redo())
|
||||
root.bind_all('<Control-n>', lambda event: self._newImage())
|
||||
root.bind_all('<Control-o>', lambda event: self._openImage())
|
||||
root.bind_all('<Control-s>', lambda event: self._saveImage())
|
||||
root.bind_all('<Control-k>', 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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
106
src/utils/file_operations.py
Normal file
106
src/utils/file_operations.py
Normal file
@@ -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)}")
|
||||
|
||||
64
src/utils/image_properties.py
Normal file
64
src/utils/image_properties.py
Normal file
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user