Private/Invoke-Paint.ps1

[Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingWriteHost","",Scope="Function")]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions","",Scope="Function")]
param ()

$script:AppSettings = $null
$script:KeyBindings = $null
$script:SwitchToolControlHeader = $null
$script:Commands = $null
$script:NavigationControls = $null
$script:Image = $null
$script:ImageWidth = $null
$script:ImageHeight = $null

$script:Tools = @("Pen", "Fill", "Snake", "Dropper", "Pen Eraser", "Fill Eraser")
$script:CurrentHue = 0
$script:CurrentSaturation = 100
$script:CurrentValue = 100
$script:HueChunkSize = 36
$script:CurrentTool = "Pen"
$script:ForceRefresh = $false
$script:BackgroundColors = @(@(35, 35, 35), @(30, 30, 30))

$script:ToolboxDivider = "------------------"
$script:UndoStates = [System.Collections.Stack]::new()
$script:RedoStates = [System.Collections.Stack]::new()

function Convert-HsvToRgb {
    param(
        [int] $Hue,
        [int] $Saturation,
        [int] $Value
    )

    $valuePercent = $Value / 100.0

    $chroma = $valuePercent * ($Saturation / 100.0)
    $H = $Hue / 60.0
    $X = $chroma * (1.0 - [Math]::Abs($H % 2 - 1))

    $m = $valuePercent - $chroma
    $rgb = @($m, $m, $m)

    $xIndex = (7 - [Math]::Floor($H)) % 3
    $cIndex = [int]($H / 2) % 3

    if($xIndex -eq $cIndex) {
        $cIndex = ($cIndex + 1) % 3
    }

    $rgb[$xIndex] += $X
    $rgb[$cIndex] += $chroma

    return @(
        [int]($rgb[0] * 255),
        [int]($rgb[1] * 255),
        [int]($rgb[2] * 255)
    )
}

function Find-Hsv {
    param (
        [array] $Rgb
    )
    for($h = 0; $h -lt 360; $h += $script:HueChunkSize) {
        for($s = 0; $s -le 100; $s += 20) {
            for($v = 0; $v -le 100; $v += 20) {
                $colorSearch = Convert-HsvToRgb -Hue $h -Saturation $s -Value $v
                if(
                    [Math]::Abs($colorSearch[0] - $Rgb[0]) -lt 5 -and
                    [Math]::Abs($colorSearch[1] - $Rgb[1]) -lt 5 -and
                    [Math]::Abs($colorSearch[2] - $Rgb[2]) -lt 5
                ) {
                    return @{
                        H = $h
                        S = $s
                        V = $v
                    }
                }
            }
        }
    }
}

function Get-Color {
    param (
        [int] $R,
        [int] $G,
        [int] $B,
        [array] $Rgb,
        [array] $ForegroundRgb,
        [string] $Content = " "
    )
    if($Rgb) {
        $R = $Rgb[0]
        $G = $Rgb[1]
        $B = $Rgb[2]
    }
    $cursorIndicatorColor = "255;255;255"
    if(($R + ($G + 5) + $B) -gt 255) {
        $cursorIndicatorColor = "0;0;0"
    }
    if($ForegroundRgb) {
        if(!($ForegroundRgb[0] -eq $R -and $ForegroundRgb[1] -eq $G -and $ForegroundRgb[2] -eq $B)) {
            $cursorIndicatorColor = "$($ForegroundRgb[0]);$($ForegroundRgb[1]);$($ForegroundRgb[2])"
        }
    }
    return ("$([Char]27)[48;2;${R};${G};${B}m$([Char]27)[38;2;${cursorIndicatorColor}m$Content$([Char]27)[0m")
}

function Get-ForegroundColoredText {
    param (
        [int] $R,
        [int] $G,
        [int] $B,
        [array] $Rgb,
        [string] $Content = ""
    )
    if($Rgb) {
        $R = $Rgb[0]
        $G = $Rgb[1]
        $B = $Rgb[2]
    }
    return ("$([Char]27)[38;2;${R};${G};${B}m$Content$([Char]27)[0m")
}

function Write-ToolboxHeaderCollection {
    param (
        [array] $Headers,
        [int] $X,
        [int] $Y
    )
    foreach($header in $Headers) {
        [Console]::SetCursorPosition($X, [int]$Y++)
        [Console]::Write($header)
    }
    return $Y
}

function Write-ToolboxControlCollection {
    param (
        [array] $Controls,
        [string] $CurrentControl,
        [int] $X,
        [int] $Y
    )
    foreach($tool in $Controls) {
        [Console]::SetCursorPosition($X, [int]$Y++)
        if(-not [string]::IsNullOrEmpty($CurrentControl)) {
            if($tool -eq $CurrentControl) {
                $tool = "[x] " + $tool
            } else {
                $tool = "[ ] " + $tool
            }
        }
        Write-Host -NoNewline -ForegroundColor DarkGray $tool
    }
    return $Y
}

function Get-AnnotatedCommandCollection {
    param (
        [array] $Commands,
        [string] $Section
    )
    $annotatedCommands = @()
    foreach($command in $Commands) {
        $keybinding = $script:Keybindings."$Section"."$command"
        $modifier = $null
        if($keybinding.Modifier) {
            $bound = $command + ("(" + $keybinding.Modifier + "+" + $keybinding.Key).PadLeft($script:ToolboxDivider.Length - 1 - $command.Length) + ")"
            $modifier = $keybinding.Modifier
        } else {
            $bound = $command + ("(" + $keybinding.Key).PadLeft($script:ToolboxDivider.Length - 1 - $command.Length) + ")"
        }
        $annotatedCommands += @{
            Text = $bound
            Key = $keybinding.Key
            Modifier = $modifier
        }
    }
    return $annotatedCommands
}

function Get-ActionForKey {
    param (
        [object] $Key
    )

    if($Key.Key -eq "Spacebar") {
        return "Spacebar"
    }

    foreach($section in $script:Keybindings.PSObject.Properties.Value) {
        if($Key.Modifiers -ne 0) {
            $foundKey = $section.PSObject.Properties | Where-Object { $_.Value.Key -eq $Key.Key -and $_.Value.Modifier -eq $Key.Modifiers }
            if($foundKey) {
                return $foundKey.Name
            }
        }
    }

    foreach($section in $script:Keybindings.PSObject.Properties.Value) {
        $foundKey = $section.PSObject.Properties | Where-Object { $_.Value.Key -eq $Key.Key }
        if($foundKey) {
            return $foundKey.Name
        }
    }
}

function Get-SwitchToolControlHeader {
    $toolKeybinding = $script:Keybindings.Tools.SwitchTool
    if($toolKeybinding.Modifier) {
        $bound = $toolKeybinding.Modifier + "+" + $toolKeybinding.Key
    } else {
        $bound = $toolKeybinding.Key
    }
    return "Tools: " + ("(" + $bound + ")").PadLeft($script:ToolboxDivider.Length - 6 - $bound.Length)
}

function Write-Toolbox {
    param (
        [object] $ToolboxTopLeft
    )

    if($ToolboxTopLeft.X -lt 56) {
        $ToolboxTopLeft.X = 56
    }

    $toolboxOffsetY = $ToolboxTopLeft.Y
    $toolboxOffsetY = Write-ToolboxHeaderCollection -Headers @($script:SwitchToolControlHeader, $script:ToolboxDivider) -X $ToolboxTopLeft.X -Y $toolboxOffsetY
    $toolboxOffsetY = Write-ToolboxControlCollection -Controls $script:Tools -CurrentControl $script:CurrentTool -X $ToolboxTopLeft.X -Y $toolboxOffsetY

    $toolboxOffsetY = Write-ToolboxHeaderCollection -Headers @("", "Commands:", $script:ToolboxDivider) -X $ToolboxTopLeft.X -Y $toolboxOffsetY
    $toolboxOffsetY = Write-ToolboxControlCollection -Controls $script:Commands.Text -X $ToolboxTopLeft.X -Y $toolboxOffsetY

    $toolboxOffsetY = Write-ToolboxHeaderCollection -Headers @("", "Navigation:", $script:ToolboxDivider) -X $ToolboxTopLeft.X -Y $toolboxOffsetY
    $toolboxOffsetY = Write-ToolboxControlCollection -Controls $script:NavigationControls.Text -X $ToolboxTopLeft.X -Y $toolboxOffsetY
}

function Get-ColorKeyCollection {
    $types = @("Hue", "Saturation", "Value")
    $controlText = @{}
    foreach($type in $types) {
        $leftControl = $script:KeyBindings.Color."${type}Left"
        if($leftControl.Modifier) {
            $controlText[$type] = $leftControl.Modifier + "+" + $leftControl.Key + "/"
        } else {
            $controlText[$type] = $leftControl.Key + "/"
        }

        $rightControl = $script:KeyBindings.Color."${type}Right"
        if($rightControl.Modifier) {
            $controlText[$type] += $rightControl.Modifier + "+" + $rightControl.Key
        } else {
            $controlText[$type] += $rightControl.Key
        }
    }

    return @{
        Hue = $controlText["Hue"]
        Saturation = $controlText["Saturation"]
        Value = $controlText["Value"]
    }
}

function Write-ColorControlCollection {
    $controls = [System.Text.StringBuilder]::new()
    $controls.AppendLine() | Out-Null
    $currentColor = (Get-Color -Rgb (Convert-HsvToRgb -Hue $script:CurrentHue -Saturation $script:CurrentSaturation -Value $script:CurrentValue) -Content " ")
    $controls.Append(" $currentColor ") | Out-Null
    for($h = 0; $h -lt 360; $h += $script:HueChunkSize) {
        $content = " "
        if($h -eq $script:CurrentHue) {
            $content = " H "
        }
        $controls.Append( (Get-Color -Rgb (Convert-HsvToRgb -Hue $h -Saturation 100 -Value 100) -Content $content) ) | Out-Null
    }
    $controls.Append(" $($script:ColorKeys.Hue) `n $currentColor ") | Out-Null
    for($s = 0; $s -le 100; $s += 20) {
        $content = " "
        if($s -eq $script:CurrentSaturation) {
            $content = " S "
        }
        if($s -eq 0) {
            $content = $content.Substring(0, $content.Length - 1)
        }
        $controls.Append( (Get-Color -Rgb (Convert-HsvToRgb -Hue $script:CurrentHue -Saturation $s -Value $script:CurrentValue) -Content $content) ) | Out-Null
    }
    $controls.Append(" $($script:ColorKeys.Saturation) `n $currentColor ") | Out-Null
    for($v = 0; $v -le 100; $v += 20) {
        $content = " "
        if($v -eq $script:CurrentValue) {
            $content = " V "
        }
        if($v -eq 0) {
            $content = $content.Substring(0, $content.Length - 1)
        }
        $controls.Append( (Get-Color -Rgb (Convert-HsvToRgb -Hue $script:CurrentHue -Saturation $script:CurrentSaturation -Value $v) -Content $content) ) | Out-Null
    }
    $controls.Append(" $($script:ColorKeys.Value) ") | Out-Null
    [Console]::WriteLine($controls)
}

function Write-Cursor {
    param (
        [object] $CurrentPosition
    )

    $cursorColor = Convert-HsvToRgb -Hue $script:CurrentHue -Saturation $script:CurrentSaturation -Value $script:CurrentValue
    for($x = -1; $x -lt 2; $x++) {
        $relativeX = $CurrentPosition.X + $x
        for($y = -1; $y -lt 2; $y++) {
            if($x -eq 0 -and $y -eq 0) {
                continue
            }
            $relativeY = $CurrentPosition.Y + $y
            if($relativeX -ge 0 -and $relativeX -lt $script:ImageWidth -and $relativeY -ge 0 -and $relativeY -lt $script:ImageHeight) {
                $currentCharacter = [char]0x2588
                if($y -lt 0) {
                    $currentCharacter = [char]0x2584
                } elseif($y -gt 0) {
                    $currentCharacter = [char]0x2580
                }
                $currentContent = "$currentCharacter$currentCharacter"
                if($x -lt 0) {
                    $currentContent = " $currentCharacter"
                } elseif($x -gt 0) {
                    $currentContent = "$currentCharacter "
                }
                $currentPixel = $script:Image[$relativeX][$relativeY]
                if($null -eq $currentPixel) {
                    $currentPixel = $script:BackgroundColors[(($relativeX + $relativeY) % 2)]
                }
                [Console]::SetCursorPosition($CanvasTopLeft.X + ($relativeX * 2), $CanvasTopLeft.Y + $relativeY)
                [Console]::Write((Get-Color -Rgb $currentPixel -ForegroundRgb $cursorColor -Content $currentContent))
            }
        }
    }
}

function Write-Frame {
    param (
        [object] $CanvasTopLeft,
        [object] $CurrentPosition,
        [object] $PreviousPosition
    )
    $cursorColor = Convert-HsvToRgb -Hue $script:CurrentHue -Saturation $script:CurrentSaturation -Value $script:CurrentValue
    if($script:CurrentTool -eq "Eraser") {
        $cursorColor = @(255, 255, 255)
    }

    Write-Toolbox -ToolboxTopLeft @{
        X = ($CanvasTopLeft.X + $script:ImageWidth) * 2 + 1
        Y = $CanvasTopLeft.Y
    }

    if(!$script:ForceRefresh -and ($PreviousPosition.X -ne $currentPosition.X -or $PreviousPosition.Y -ne $currentPosition.Y)) {
        # Just render the changes
        for($x = -1; $x -lt 2; $x++) {
            $relativeX = $PreviousPosition.X + $x
            for($y = -1; $y -lt 2; $y++) {
                $relativeY = $PreviousPosition.Y + $y
                if($relativeX -ge 0 -and $relativeX -lt $script:ImageWidth -and $relativeY -ge 0 -and $relativeY -lt $script:ImageHeight) {
                    [Console]::SetCursorPosition($CanvasTopLeft.X + ($relativeX * 2), $CanvasTopLeft.Y + $relativeY)
                    $currentPixel = $script:Image[$relativeX][$relativeY]
                    if($null -ne $currentPixel) {
                        Write-Host -NoNewline (Get-Color -Rgb $currentPixel)
                    } else {
                        Write-Host -NoNewline (Get-Color -Rgb $script:BackgroundColors[(($relativeX + $relativeY) % 2)])
                    }
                }
            }
        }
    } else {
        # Render the entire image
        $frame = [System.Text.StringBuilder]::new()
        for($y = 0; $y -lt $script:ImageHeight; $y++) {
            for($x = 0; $x -lt $script:ImageWidth; $x++) {
                if($y -eq $CurrentPosition.Y -and ($x + 1) -eq $CurrentPosition.X) {
                    $frame.Append( (Get-Color -Rgb $cursorColor -Content "$([char]0x257A)$([char]0x2578)") ) | Out-Null
                } else {
                    $currentPixel = $script:Image[$x][$y]
                    if($null -ne $currentPixel) {
                        $frame.Append( (Get-Color -Rgb $currentPixel) ) | Out-Null
                    } else {
                        $frame.Append( (Get-Color -Rgb $script:BackgroundColors[(($x + $y) % 2)]) ) | Out-Null
                    }
                }
            }
            $frame.AppendLine() | Out-Null
        }

        [Console]::SetCursorPosition($CanvasTopLeft.X, $CanvasTopLeft.Y)
        [Console]::Write($frame)
        $script:ForceRefresh = $false
    }

    Write-Cursor -CurrentPosition $CurrentPosition
    [Console]::SetCursorPosition($CanvasTopLeft.X, $CanvasTopLeft.Y + $script:ImageHeight)
    Write-ColorControlCollection
}

function Out-Png {
    param (
        [string] $Path
    )
    $attempts = 0
    while($attempts -lt 2) {
        $attempts++
        try {
            $bitmap = [System.Drawing.Bitmap]::new($script:ImageWidth, $script:ImageHeight, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
            $palette = @()
            for($y = 0; $y -lt $script:ImageHeight; $y++) {
                for($x = 0; $x -lt $script:ImageWidth; $x++) {
                    $currentPixel = $script:Image[$x][$y]
                    if($null -ne $currentPixel) {
                        $c = [System.Drawing.Color]::FromArgb(255, $currentPixel[0], $currentPixel[1], $currentPixel[2])
                        $bitmap.SetPixel($x, $y, $c)
                        if(!$palette.Contains($c)) {
                            $palette += $c
                        }
                    } else {
                        $bitmap.SetPixel($x, $y, [System.Drawing.Color]::FromArgb(0, 0, 0, 0))
                    }
                }
            }

            $bitmap.Save(($Path -replace "[^.]+$", "png"), [System.Drawing.Imaging.ImageFormat]::Png)
            return
        } catch {
            [System.Reflection.Assembly]::LoadWithPartialName("System.Drawing") | Out-Null
        }
    }
    Write-Warning "Png was not saved, this is only supported on Windows"
}

function Open-JsonPainting {
    param (
        [string] $Path,
        [string] $Json,
        [bool] $ResetUndo = $true
    )
    if($Path) {
        $Obj = Get-Content $Path | ConvertFrom-Json
    } else {
        $Obj = $Json | ConvertFrom-Json
    }

    if($ResetUndo) {
        $script:UndoStates = [System.Collections.Stack]::new()
        $script:RedoStates = [System.Collections.Stack]::new()
    }

    # Json saving on powershell 5 saves arrays with simple types as complex objects instead of vanilla json arrays
    # convert these back by grabbing their "value"
    $script:Image = @($null) * $Obj.Count
    for($i = 0; $i -lt $script:Image.Count; $i++){
        if("value" -in $Obj[$i].PSObject.Properties) {
            $script:Image[$i] = $Obj[$i].value
        } else {
            $script:Image[$i] = $Obj[$i]
        }
    }
}

function Save-JsonPainting {
    param (
        [string] $Path
    )
    while($true) {
        Clear-Host
        Write-Host "~ PwshPaint - Save your pixel art`n"
        [Console]::CursorVisible = $true
        if($Path) {
            $defaultPath = $Path
            Write-Host -ForegroundColor DarkGray "Press ENTER to use the default '$Path'"
            Write-Host -NoNewline -ForegroundColor DarkGray "Enter a filename or path to save the image json: "
            $Path = Read-Host
            if([string]::IsNullOrEmpty($Path)) {
                $Path = $defaultPath
            }
        } else {
            Write-Host -NoNewline -ForegroundColor DarkGray "Enter a filename or path to save the image json: "
            $Path = Read-Host
        }
        if($Path -notmatch "\.json$") {
            $Path = $Path + ".json"
        }
        if($Path -eq (Split-Path $Path -Leaf)) {
            $Path = Join-Path "$PSScriptRoot/../Images" $Path
        }
        if(Test-Path $Path) {
            Write-Host -ForegroundColor Yellow -NoNewline "A file exists at $Path, do you want to overwrite it? (y/n) "
            $answer = Read-Host
            if($answer -ne "y") {
                continue
            }
        }
        $c = $script:Image | ConvertTo-Json -Depth 25
        try {
            Set-Content -Path $Path -Value $c
            Out-Png -Path ($Path -replace "[^\.]+$", "png")
        } catch {
            Write-Host -ForegroundColor Yellow -NoNewline "Failed to save at '$Path', try another file location"
            [Console]::CursorVisible = $false
            0..3 | Foreach-Object {
                Start-Sleep -Milliseconds 500
                Write-Host -ForegroundColor Yellow -NoNewline "."
            }
            continue
        }
        Write-Host -ForegroundColor DarkGray -NoNewline "Saved at '$Path'"
        [Console]::CursorVisible = $false
        0..3 | Foreach-Object {
            Start-Sleep -Milliseconds 500
            Write-Host -ForegroundColor DarkGray -NoNewline "."
        }
        Clear-Host
        Write-Host -NoNewline "~ PwshPaint "
        Write-Host -ForegroundColor DarkGray "$(Split-Path $Path -Leaf)`n"
        break
    }
}

function Open-JsonPaintingDialog {
    while($true) {
        Clear-Host
        Write-Host "~ PwshPaint - Open a saved pixel art"
        $jsonFiles = Get-ChildItem "$PSScriptRoot/../Images/" -Filter "*.json"
        $latestJsonFiles = $jsonFiles `
            | Select-Object Name, FullName, @{ Name = "Last Modified"; Expression = { (Get-ItemProperty $_.FullName).LastWriteTime} } `
            | Sort-Object { $_."Last Modified" } `
            | Select-Object -Last 10 *
        $script:i = $latestJsonFiles.Count
        $output = $latestJsonFiles | Select-Object @{ Name = "#"; Expression = { [int]$script:i-- }}, Name, "Last Modified"
        Write-Host -Foreground DarkGray ($output | Format-Table * | Out-String).TrimEnd()
        [Console]::CursorVisible = $true
        Write-Host -Foreground DarkGray -NoNewline "`nEnter the # of the recent file to open or enter a file path: "
        $Path = Read-Host
        [Console]::CursorVisible = $false
        if([int]::TryParse($Path, [ref]$null)) {
            $Path = $latestJsonFiles[($script:i - $Path)].FullName
        }
        if(Test-Path $Path) {
            Open-JsonPainting -Path $Path
            $script:ImageWidth = $script:Image.Count
            $script:ImageHeight = $script:Image[0].Count
            if(!(Test-CanvasFitsInTerminal)) {
                Write-Warning "Your canvas is too large for the terminal window, try zooming out"
                while(!(Test-CanvasFitsInTerminal)) {
                    Start-Sleep -Milliseconds 100
                }
            }
            Clear-Host
            Write-Host -NoNewline "~ PwshPaint "
            Write-Host -ForegroundColor DarkGray "$(Split-Path $Path -Leaf)`n"
            break
        } else {
            Write-Error "File '$Path' doesn't exist"
        }
    }
}


function New-JsonPaintingDialog {
    Clear-Host
    Write-Host "~ PwshPaint - Create a new canvas`n"
    while($true) {
        Write-Host -ForegroundColor DarkGray -NoNewline "Enter a width in pixels: "
        $script:ImageWidth = Read-Host
        Write-Host -ForegroundColor DarkGray -NoNewline "Enter a height in pixels: "
        $script:ImageHeight = Read-Host
        if(!(Test-CanvasFitsInTerminal)) {
            Write-Warning "Your canvas width is too large for the terminal window, try zooming out and entering the size you want again"
            continue
        }
        $script:Image = @($null) * $script:ImageWidth
        for($x = 0; $x -lt $script:ImageWidth; $x++) {
            $script:Image[$x] = @($null) * $script:ImageHeight
        }
        Clear-Host
        Write-Host "~ PwshPaint`n"
        break
    }
}

function Test-CanvasFitsInTerminal {
    return ($script:ImageWidth -lt (($Host.UI.RawUI.WindowSize.Width - $script:ToolboxDivider.Length) / 2) -and $script:ImageHeight -lt ($Host.UI.RawUI.WindowSize.Height - 7))
}

function Wait-ForCanvasToFitInTerminal {
    [Console]::TreatControlCAsInput = $false
    if(!(Test-CanvasFitsInTerminal)) {
        Write-Warning "Your canvas is too large for the terminal window, try zooming out"
        while(!(Test-CanvasFitsInTerminal)) {
            Start-Sleep -Milliseconds 100
        }
    }
    Clear-Host
    Write-Host -NoNewline "~ PwshPaint "
    if($Path) {
        Write-Host -ForegroundColor DarkGray "$(Split-Path $Path -Leaf)`n"
    } else {
        Write-Host "`n"
    }
}

function Add-Fill {
    param (
        [array] $OriginalColor,
        [object] $CurrentPosition
    )

    $currentFillColor = (Convert-HsvToRgb -Hue $script:CurrentHue -Saturation $script:CurrentSaturation -Value $script:CurrentValue)

    if($null -ne $OriginalColor -and $null -eq (Compare-Object -ReferenceObject $currentFillColor -DifferenceObject $script:Image[$CurrentPosition.X][$CurrentPosition.Y] -SyncWindow 0)) {
        return
    } else {
        $script:Image[$CurrentPosition.X][$CurrentPosition.Y] = $currentFillColor
    }

    # Don't want to color on diagonals
    $pixelsToTryColor = @(
        @(-1, 0),
        @(1, 0),
        @(0, 1),
        @(0, -1)
    )

    foreach($pixel in $pixelsToTryColor) {
        $relativeX = $CurrentPosition.X + $pixel[0]
        $relativeY = $CurrentPosition.Y + $pixel[1]
        if($relativeX -ge 0 -and $relativeX -lt $script:ImageWidth -and $relativeY -ge 0 -and $relativeY -lt $script:ImageHeight) {
            $refObject = $script:Image[$relativeX][$relativeY]
            $diffObject = $OriginalColor
            if($null -eq $refObject) {
                $refObject = @(-1, -1, -1)
            }
            if($null -eq $diffObject) {
                $diffObject = @(-1, -1, -1)
            }
            if($null -eq (Compare-Object -ReferenceObject $refObject -DifferenceObject $diffObject -SyncWindow 0)) {
                Add-Fill -OriginalColor $OriginalColor -CurrentPosition @{ X = $relativeX; Y = $relativeY } -ImageWidth $script:ImageWidth -ImageHeight $script:ImageHeight
            }
        }
    }
}

function Remove-Fill {
    param (
        [array] $OriginalColor,
        [object] $CurrentPosition
    )
    if($null -eq $script:Image[$CurrentPosition.X][$CurrentPosition.Y]) {
        return
    }

    $script:Image[$CurrentPosition.X][$CurrentPosition.Y] = $null
    $pixelsToTryColor = @(
        @(-1, 0),
        @(1, 0),
        @(0, 1),
        @(0, -1)
    )

    foreach($pixel in $pixelsToTryColor) {
        $relativeX = $CurrentPosition.X + $pixel[0]
        $relativeY = $CurrentPosition.Y + $pixel[1]
        if($relativeX -ge 0 -and $relativeX -lt $script:ImageWidth -and $relativeY -ge 0 -and $relativeY -lt $script:ImageHeight) {
            $refObject = $script:Image[$relativeX][$relativeY]
            $diffObject = $OriginalColor
            if($null -eq $refObject) {
                $refObject = @(-1, -1, -1)
            }
            if($null -eq $diffObject) {
                $diffObject = @(-1, -1, -1)
            }
            if($null -eq (Compare-Object -ReferenceObject $refObject -DifferenceObject $diffObject -SyncWindow 0)) {
                Remove-Fill -OriginalColor $OriginalColor -CurrentPosition @{ X = $relativeX; Y = $relativeY } -ImageWidth $script:ImageWidth -ImageHeight $script:ImageHeight
            }
        }
    }
}

function Push-UndoState {
    $state = $script:Image | ConvertTo-Json -Depth 25
    $script:UndoStates.Push($state)
}

function Pop-UndoState {
    $currentState = $script:Image | ConvertTo-Json -Depth 25
    $targetState = $currentState
    while($script:UndoStates.Count -gt 0) {
        $targetState = $script:UndoStates.Pop()
        $script:RedoStates.Push($targetState)
        if($null -ne (Compare-Object -ReferenceObject $currentState -DifferenceObject $targetState -SyncWindow 0)) {
            break
        }
    }
    Open-JsonPainting -Json $targetState -ResetUndo $false
}

function Pop-RedoState {
    $currentState = $script:Image | ConvertTo-Json -Depth 25
    $targetState = $currentState
    while($script:RedoStates.Count -gt 0) {
        $targetState = $script:RedoStates.Pop()
        $script:UndoStates.Push($targetState)
        if($null -ne (Compare-Object -ReferenceObject $currentState -DifferenceObject $targetState -SyncWindow 0)) {
            break
        }
    }
    Open-JsonPainting -Json $targetState -ResetUndo $false
}

function Invoke-Paint {
    <#
    .Synopsis
        Start the PwshPaint terminal based image editor
    .Description
        Opens a terminal based image editor that reads and writes image data from JSON files located in the Images folder of the module
    .Example
        # Open the painting editor with a new empty image
        Invoke-Paint
    .Example
        # Open the painting editor with a new empty image of a specific size
        Invoke-Paint -ImageWidth 10 -ImageHeight 10
    .Example
        # Open the painting editor with an existing image from the module images folder
        Invoke-Paint -Path "clippy.json"
    .Example
        # Open the painting editor with an existing image from an absolute file path
        Invoke-Paint -Path "C:\Users\shaun\Desktop\hello.json"
    .Example
        # Open the painting editor with the Vim keybindings (hjkl) instead of arrow keys for navigation
        Invoke-Paint -VimBindings
    #>

    param (
        [string] $Path,
        [int] $ImageWidth = 28,
        [int] $ImageHeight = 28,
        [switch] $VimBindings
    )

    $AppSettingsPath = if($VimBindings) { "$PSScriptRoot/appsettings.vim.json" } else { "$PSScriptRoot/appsettings.json" }

    if(!(Test-Path $AppSettingsPath)) {
        Write-Error "Could not find a settings file at '$AppSettingsPath'"
    }

    $script:AppSettings = Get-Content $AppSettingsPath | ConvertFrom-Json
    $script:KeyBindings = $script:AppSettings.Keybindings
    $script:SwitchToolControlHeader = Get-SwitchToolControlHeader
    $script:ColorKeys = Get-ColorKeyCollection
    $script:Commands = Get-AnnotatedCommandCollection -Commands @("New", "Open", "Save", "Undo", "Redo", "Close") -Section "Commands"
    $script:NavigationControls = Get-AnnotatedCommandCollection -Commands @("Left", "Right", "Up", "Down", "Draw/Use") -Section "Navigation"
    $script:Image = @($null) * $ImageWidth
    $script:ImageWidth = $ImageWidth
    $script:ImageHeight = $ImageHeight

    if($Path) {
        if(Test-Path $Path) {
            Open-JsonPainting -Path $Path
            $ImageWidth = $script:Image.Count
            $ImageHeight = $script:Image[0].Count
        } elseif(Test-Path "$PSScriptRoot/../Images/$Path") {
            $Path = "$PSScriptRoot/../Images/$Path"
            Open-JsonPainting -Path $Path
            $ImageWidth = $script:Image.Count
            $ImageHeight = $script:Image[0].Count
        } else {
            Write-Error "Could not find an image to load at $Path"
        }
    } else {
        for($x = 0; $x -lt $ImageWidth; $x++) {
            $script:Image[$x] = @($null) * $ImageHeight
        }
    }
    Push-UndoState

    Wait-ForCanvasToFitInTerminal
    $CanvasTopLeft = $Host.UI.RawUI.CursorPosition
    $currentPosition = @{ X = 0; Y = 0 }
    [Console]::CursorVisible = $false
    $previousPosition = @{
        X = $currentPosition.X
        Y = $currentPosition.Y
    }
    $previousWindowSize = @{
        X = $Host.UI.RawUI.WindowSize.Width
        Y = $Host.UI.RawUI.WindowSize.Height
    }

    try {
        while($true) {
            Write-Frame -CanvasTopLeft $CanvasTopLeft -CurrentPosition $currentPosition -PreviousPosition $previousPosition
            $inputReceived = $false
            while(!$inputReceived) {
                # Redraw on window resize
                if($previousWindowSize.X -ne $Host.UI.RawUI.WindowSize.Width -or $previousWindowSize.Y -ne $Host.UI.RawUI.WindowSize.Height) {
                    Clear-Host
                    Wait-ForCanvasToFitInTerminal -Path $Path
                    $previousWindowSize = @{
                        X = $Host.UI.RawUI.WindowSize.Width
                        Y = $Host.UI.RawUI.WindowSize.Height
                    }
                    $script:ForceRefresh = $true
                    $inputReceived = $true
                    break
                }
                [Console]::TreatControlCAsInput = $true
                $key = [Console]::ReadKey($true)
                $previousPosition = @{
                    X = $currentPosition.X
                    Y = $currentPosition.Y
                }
                $action = Get-ActionForKey -Key $key
                switch($action) {
                    "Left" {
                        $currentPosition.X = [Math]::Max($currentPosition.X - 1, 0)
                    }
                    "Right" {
                        $currentPosition.X = [Math]::Min($currentPosition.X + 1, $ImageWidth - 1)
                    }
                    "Up" {
                        $currentPosition.Y = [Math]::Max($currentPosition.Y - 1, 0)
                    }
                    "Down" {
                        $currentPosition.Y = [Math]::Min($currentPosition.Y + 1, $ImageHeight - 1)
                    }
                    "HueLeft" {
                        $script:CurrentHue = [Math]::Max($script:CurrentHue - $script:HueChunkSize, 0)
                        $inputReceived = $true
                    }
                    "HueRight" {
                        $script:CurrentHue = [Math]::Min($script:CurrentHue + $script:HueChunkSize, 360 - $script:HueChunkSize)
                        $inputReceived = $true
                    }
                    "SaturationLeft" {
                        $script:CurrentSaturation = [Math]::Max($script:CurrentSaturation - 20, 0)
                        $inputReceived = $true
                    }
                    "SaturationRight" {
                        $script:CurrentSaturation = [Math]::Min($script:CurrentSaturation + 20, 100)
                        $inputReceived = $true
                    }
                    "Undo" {
                        Pop-UndoState
                        $script:ForceRefresh = $true
                        $inputReceived = $true
                    }
                    "ValueLeft" {
                        $script:CurrentValue = [Math]::Max($script:CurrentValue - 20, 0)
                        $inputReceived = $true
                    }
                    "ValueRight" {
                        $script:CurrentValue = [Math]::Min($script:CurrentValue + 20, 100)
                        $inputReceived = $true
                    }
                    "SwitchTool" {
                        $index = $script:Tools.IndexOf($script:CurrentTool)
                        if($key.Modifiers -eq "Shift") {
                            $targetIndex = $index - 1
                            if($targetIndex -lt 0) {
                                $targetIndex = $script:Tools.Count - 1
                            }
                        } else {
                            $targetIndex = ($index + 1) % $script:Tools.Count
                        }
                        $script:CurrentTool = $script:Tools[$targetIndex]
                        $inputReceived = $true
                    }
                    "Open" {
                        Open-JsonPaintingDialog
                        $inputReceived = $true
                    }
                    "New" {
                        New-JsonPaintingDialog
                        $inputReceived = $true
                    }
                    "Save" {
                        Save-JsonPainting -Path $Path
                        $inputReceived = $true
                    }
                    "Spacebar" {

                        switch($script:CurrentTool) {
                            "Pen" {
                                $script:Image[$currentPosition.X][$currentPosition.Y] = (Convert-HsvToRgb -Hue $script:CurrentHue -Saturation $script:CurrentSaturation -Value $script:CurrentValue)
                            }
                            "Snake" {
                                $script:Image[$currentPosition.X][$currentPosition.Y] = (Convert-HsvToRgb -Hue $script:CurrentHue -Saturation $script:CurrentSaturation -Value $script:CurrentValue)
                            }
                            "Pen Eraser" {
                                $script:Image[$currentPosition.X][$currentPosition.Y] = $null
                            }
                            "Fill" {
                                Add-Fill -OriginalColor $script:Image[$currentPosition.X][$currentPosition.Y] -CurrentPosition $currentPosition
                                $script:ForceRefresh = $true
                            }
                            "Fill Eraser" {
                                Remove-Fill -OriginalColor $script:Image[$currentPosition.X][$currentPosition.Y] -CurrentPosition $currentPosition
                                $script:ForceRefresh = $true
                            }
                            "Dropper" {
                                $originalColor = $script:Image[$currentPosition.X][$currentPosition.Y]
                                if($null -ne $originalColor) {
                                    $result = Find-Hsv -Rgb $originalColor
                                    if($result) {
                                        $script:CurrentHue = $result.H
                                        $script:CurrentSaturation = $result.S
                                        $script:CurrentValue = $result.V
                                    }
                                    $script:ForceRefresh = $true
                                }
                            }
                        }

                        Push-UndoState
                        $inputReceived = $true
                    }
                    "Close" {
                        $inputReceived = $true
                        return
                    }
                    "Redo" {
                        Pop-RedoState
                        $script:ForceRefresh = $true
                        $inputReceived = $true
                    }
                }
                if($previousPosition.X -ne $currentPosition.X -or $previousPosition.Y -ne $currentPosition.Y) {
                    if($script:CurrentTool -eq "Snake") {
                        $script:Image[$currentPosition.X][$currentPosition.Y] = (Convert-HsvToRgb -Hue $script:CurrentHue -Saturation $script:CurrentSaturation -Value $script:CurrentValue)
                        Push-UndoState
                    }
                    $inputReceived = $true
                }
            }
        }
    } finally {
        [Console]::CursorVisible = $true
    }
}