import tkinter as tk
from tkinter import filedialog, messagebox, colorchooser, simpledialog
from PIL import ImageTk, ImageDraw
from ImageContainer import ImageContainer
from ImageManipulation.ManipulationList import *
from SelectionArea import SelectionArea
from functools import partial


class GUI:
    """The GUI class responsible for the main application GUI.

    @warning This class is a singleton.
    """
    _instance = None

    # If the GUI has been initialised.
    _isInitialised = False

    _currentImage = None

    # Interactive selection variables
    _selectionMode = False
    _selectionStartX = None
    _selectionStartY = None
    _selectionEndX = None
    _selectionEndY = None
    _selectionRectangle = None
    _selectionArea = 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

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super(GUI, cls).__new__(cls)
        return cls._instance

    def initialise(self):
        """Initialise the GUI."""

        if self._isInitialised:
            return
        else:
            self._isInitialised = True

        # Main window
        root = tk.Tk()
        root.title("Image Viewer")
        root.geometry("1100x700")
        root.config(bg="white")
        icon = tk.PhotoImage(file='icon.png')
        root.tk.call('wm', 'iconphoto', root._w, icon)
        root.minsize(1100, 600)

        # Menus
        menu = tk.Menu(root)
        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", command=lambda: self._openImage())
        file_menu.add_command(label="Open Camera", command=lambda: self._openCamera())
        file_menu.add_command(label="Save Image", command=lambda: self._saveImage())
        file_menu.add_command(label="Exit", 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)
            )

        # Manual Test menu listing all manipulations explicitly
        manual_menu = tk.Menu(menu, tearoff=0)
        menu.add_cascade(label="menuuuu", menu=manual_menu)
        manual_menu.add_command(label="Padding", command=partial(self._applyManipulation, Padding(), {"border_width": 50}))
        manual_menu.add_command(label="Crop", command=partial(self._applyManipulation, CropImage()))
        manual_menu.add_command(label="Resize", command=partial(self._applyManipulation, ResizeImage(), {"width": 200, "height": 200}))
        manual_menu.add_command(label="Copy", command=partial(self._applyManipulation, CopyImage()))

        edit_menu = tk.Menu(menu, tearoff=0)
        menu.add_cascade(label="Edit", menu=edit_menu)
        edit_menu.add_command(label="Undo", accelerator="Ctrl+Z", command=lambda: self._undo())
        edit_menu.add_command(label="Redo", accelerator="Ctrl+Y", command=lambda: self._redo())
        edit_menu.add_separator()
        edit_menu.add_command(label="Toggle Selection Mode", accelerator="Ctrl+S", command=lambda: self._toggleSelectionMode())
        edit_menu.add_command(label="Toggle Selection Shape (Rect/Circle)", 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())

        # Main container frame for image and settings
        main_container = tk.Frame(root, bg="white")
        main_container.pack(side="top", fill="both", expand=True, padx=10, pady=10)

        # Frame to hold image
        imgframe = tk.Frame(main_container, width=500, height=500, bg="lightgray", relief="sunken", bd=2)
        imgframe.pack(side="left", fill="both", expand=True, padx=(0, 10))

        # Canvas to display image and draw selection
        self._imageCanvas = tk.Canvas(imgframe, width=500, height=500, bg="white")
        self._imageCanvas.pack(expand=True, fill="both")
 
        # Label to hold the image (will be placed on canvas)
        self._imageLabel = tk.Label(self._imageCanvas, bg="white")
        
        # Bind mouse events for interactive selection
        self._imageCanvas.bind("<Button-1>", self._onMouseClick)
        self._imageCanvas.bind("<B1-Motion>", self._onMouseDrag)
        self._imageCanvas.bind("<ButtonRelease-1>", self._onMouseRelease)
        self._imageCanvas.bind("<Button-3>", self._onRightClick)  # Right-click for context menu

        # Settings panel for brush controls
        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()

        # Key bindings
        root.bind_all('<Control-z>', lambda event: self._undo())
        root.bind_all('<Control-y>', lambda event: self._redo())
        root.bind_all('<Control-s>', lambda event: self._toggleSelectionMode())
        root.bind_all('<Control-Shift-C>', lambda event: self._toggleSelectionShape())
        root.bind_all('<Control-b>', lambda event: self._toggleBrushMode())

        root.mainloop()

    def _openImage(self) -> None:
        file_path = filedialog.askopenfilename(
            filetypes=[("Image files", "*.jpg *.jpeg *.png *.gif *.bmp")]
        )
        if not file_path:
            return

        self._currentImage = ImageContainer()
        self._currentImage.loadImage(file_path)
        self._renderCurrentImage()

    def _renderCurrentImage(self) -> None:
        if self._currentImage is None or self._currentImage.getImage() is None:
            return
        
        # Clear canvas
        self._imageCanvas.delete("all")
        
        pil_img = self._currentImage.getImage()
        width, height = pil_img.size
        tk_img = ImageTk.PhotoImage(pil_img)
        
        # Keep reference to avoid garbage collection
        self._imageLabel.image = tk_img
        
        # Store canvas image dimensions for coordinate conversion
        self._canvasImageWidth = width
        self._canvasImageHeight = height
        
        # Place image on canvas
        self._imageCanvas.create_image(0, 0, anchor="nw", image=tk_img)
        
        # Update canvas size
        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()

    def _applyManipulation(self, manipulation, params: dict | None = None) -> None:
        if self._currentImage is None:
            return
        # Take undo snapshot
        self._currentImage.snapshot()
        if params is None:
            # default demo params for crop
            params = {"width": 200, "height": 200}
        manipulation.manipulateImage(self._currentImage, params)
        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)

    def _undo(self) -> None:
        if self._currentImage is None:
            return
        self._currentImage.undo()
        self._renderCurrentImage()

    def _redo(self) -> None:
        if self._currentImage is None:
            return
        self._currentImage.redo()
        self._renderCurrentImage()

    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()

    def _toggleSelectionShape(self) -> None:
        """Toggle selection shape between rectangle and circle."""
        if self._selectionArea is None:
            return
        new_shape = "circle" if self._selectionArea.shape == "rectangle" else "rectangle"
        self._selectionArea.set_shape(new_shape)
        # Refresh selection drawing if active
        if self._selectionArea.is_active and self._selectionArea.is_valid():
            self._renderCurrentImage()

    def _onMouseClick(self, event) -> None:
        """Handle mouse click for selection or brush."""
        if self._brushMode and self._currentImage is None:
            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
        
        # Clear any existing selection
        self._clearSelection()
        
        # Store starting coordinates
        self._selectionStartX = event.x
        self._selectionStartY = event.y

    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
            return
        
        # Handle selection mode
        if not self._selectionMode or self._currentImage is None or 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=""
            )

    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
            return
        
        # Handle selection mode
        if not self._selectionMode or self._currentImage is None or 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()

    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()

    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()
            
            # Crop the image to the selection area
            cropped_image = original_image.crop((left, top, right, bottom))
            
            # 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."""
        manipulation_name = manipulation.getManipulationName().lower()
        
        if "crop" in manipulation_name:
            return {"width": 200, "height": 200}
        elif "resize" in manipulation_name:
            return {"width": 200, "height": 200}
        elif "rotate" in manipulation_name:
            return {"angle": 90}
        elif "flip" in manipulation_name:
            return {"mode": "horizontal"}
        elif "color" in manipulation_name or "adjust" in manipulation_name:
            return {"brightness": 10, "contrast": 1.2, "saturation": 1.1}
        elif "blur" in manipulation_name:
            return {"ksize": 5}
        elif "hue" in manipulation_name:
            return {"hue": 50}
        elif "padding" in manipulation_name:
            return {"border_width": 50}
        elif "sobel" in manipulation_name:
            return {"dx": 1, "dy": 0, "ksize": 3}
        elif "threshold" in manipulation_name:
            return {"thresh": 127}
        else:
            return {}

    def _clearSelection(self) -> None:
        """Clear the selection rectangle and reset selection state."""
        if self._selectionRectangle:
            self._imageCanvas.delete(self._selectionRectangle)
            self._selectionRectangle = None
        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 == "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")

    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()
        camera.run(self)