Public/Show-LayeredGridMenu.ps1

Function Show-LayeredGridMenu
    {
        <#
        .SYNOPSIS
            Displays a stack of grid menus based on a list of menu definition objects.
            Menus are rendered in ascending order by Index when provided; otherwise original list order is used.
            Each completed menu is redrawn dimmed in the background while the next menu is shown over the top.
            Pressing Escape on a menu goes back one menu (if available).
 
        .EXAMPLE
            $MenuItems = @(
                @{
                    Index = 0
                    Title = "FirstMenu"
                    Items = @('Choice 1', 'Choice 2', 'Choice 3')
                    StartRow = 0
                    BorderInset = 0
                    Border = $true
                },
                @{
                    Index = 1
                    Title = "SecondMenu"
                    Items = @('Choice 1', 'Choice 2', 'Choice 3')
                    StartRow = 2
                    BorderInset = 4
                    Border = $true
                }
            )
 
            Show-LayeredGridMenu -MenuItems $MenuItems
        #>


        [CmdletBinding()]
        param(
            [Parameter(Mandatory=$True)]
            [Object[]]$MenuItems
        )

        function Get-MenuValue
            {
                param(
                    [object]$Menu,
                    [string]$Name,
                    $Default = $null
                )

                if ($null -eq $Menu)
                    {
                        return $Default
                    }

                if ($Menu -is [System.Collections.IDictionary])
                    {
                        if ($Menu.Contains($Name) -and $null -ne $Menu[$Name])
                            {
                                return $Menu[$Name]
                            }

                        return $Default
                    }

                $prop = $Menu.PSObject.Properties[$Name]
                if ($null -ne $prop -and $null -ne $prop.Value)
                    {
                        return $prop.Value
                    }

                return $Default
            }

        $NormalizedMenus = @()
        ForEach($Raw in $MenuItems)
            {
                If(-not ($Raw -is [System.Collections.IDictionary] -or $Raw -is [PSObject]))
                    {
                        throw "Each menu definition must be a hashtable or object. Invalid item: $($Raw | Out-String)"
                    }

                $MenuHash = @{}
                If($Raw -is [System.Collections.IDictionary])
                    {
                        $MenuHash = $Raw
                    }
                Else
                    {
                        ForEach($Property in $Raw.PSObject.Properties)
                            {
                                $MenuHash[$Property.Name] = $Property.Value
                            }
                    }

                $MenuIndex = $MenuItems.IndexOf($Raw)
                $NormalizedMenus += [PSCustomObject]@{
                    OriginalOrder = $MenuIndex
                    SortIndex = If($MenuHash.ContainsKey('Index') -and $null -ne $MenuHash.Index) { [int]$MenuHash.Index } else { $MenuIndex }
                    Menu = $MenuHash
                }
            }

        $Ordered = $NormalizedMenus | Sort-Object -Property SortIndex, OriginalOrder
        $IndexToPosition = @{}
        $TitleToPosition = @{}

        For($i = 0; $i -lt $Ordered.Count; $i++)
            {
                $sortIndex = [int]$Ordered[$i].SortIndex
                If(-not $IndexToPosition.ContainsKey($sortIndex))
                    {
                        $IndexToPosition[$sortIndex] = $i
                    }

                $menuTitle = [string](Get-MenuValue -Menu $Ordered[$i].Menu -Name 'Title' -Default "")
                If(-not [string]::IsNullOrEmpty($menuTitle))
                    {
                        $titleKey = $menuTitle.ToLowerInvariant()
                        If(-not $TitleToPosition.ContainsKey($titleKey))
                            {
                                $TitleToPosition[$titleKey] = $i
                            }
                    }
            }

        Function Resolve-TargetPosition
            {
                param(
                    [object]$Target
                )

                If($null -eq $Target)
                    {
                        Return $null
                    }

                If($Target -is [int] -or $Target -is [long] -or $Target -is [short] -or ($Target -is [string] -and $Target -match '^-?\d+$'))
                    {
                        $indexValue = [int]$Target
                        If($IndexToPosition.ContainsKey($indexValue))
                            {
                                Return [int]$IndexToPosition[$indexValue]
                            }

                        If($indexValue -ge 0 -and $indexValue -lt $Ordered.Count)
                            {
                                Return $indexValue
                            }

                        Return $null
                    }

                $titleValue = [string]$Target
                If([string]::IsNullOrWhiteSpace($titleValue))
                    {
                        Return $null
                    }

                $titleKey = $titleValue.ToLowerInvariant()
                If($TitleToPosition.ContainsKey($titleKey))
                    {
                        Return [int]$TitleToPosition[$titleKey]
                    }

                Return $null
            }

        Function Resolve-NextMenuPosition
            {
                param(
                    [object]$MenuDef,
                    [string]$SelectedItem,
                    [int]$CurrentPosition,
                    [Nullable[int]]$DefaultNextPosition
                )

                $nextBySelection = Get-MenuValue -Menu $MenuDef -Name 'NextBySelection' -Default $null
                If($null -ne $nextBySelection -and ($nextBySelection -is [System.Collections.IDictionary]))
                    {
                        If($nextBySelection.Contains($SelectedItem))
                            {
                                $resolved = Resolve-TargetPosition -Target $nextBySelection[$SelectedItem]
                                If($null -ne $resolved)
                                    {
                                        Return [Nullable[int]]$resolved
                                    }
                            }
                    }

                $nextIndex = Get-MenuValue -Menu $MenuDef -Name 'NextIndex' -Default $null
                If($null -ne $nextIndex)
                    {
                        $resolved = Resolve-TargetPosition -Target $nextIndex
                        If($null -ne $resolved)
                            {
                                Return [Nullable[int]]$resolved
                            }
                    }

                $nextTitle = Get-MenuValue -Menu $MenuDef -Name 'NextTitle' -Default $null
                If($null -ne $nextTitle)
                    {
                        $resolved = Resolve-TargetPosition -Target $nextTitle
                        If($null -ne $resolved)
                            {
                                Return [Nullable[int]]$resolved
                            }
                    }

                Return $DefaultNextPosition
            }

        $Selections = @{}
        $NavigationStack = New-Object 'System.Collections.Generic.List[int]'
        $CurrentMenuPos = 0

        While($CurrentMenuPos -ge 0 -and $CurrentMenuPos -lt $Ordered.Count)
            {
                $Underlay = $null

                For($j = 0; $j -lt $NavigationStack.Count; $j++)
                    {
                        $PathPosition = $NavigationStack[$j]
                        $MenuDef = $Ordered[$PathPosition].Menu
                        $OriginalOrder = $Ordered[$PathPosition].OriginalOrder

                        $Items       = @((Get-MenuValue -Menu $MenuDef -Name 'Items' -Default @()))
                        $Columns     = [int](Get-MenuValue -Menu $MenuDef -Name 'Columns' -Default 3)
                        $Padding     = [int](Get-MenuValue -Menu $MenuDef -Name 'Padding' -Default 1)
                        $FullWidth   = [bool](Get-MenuValue -Menu $MenuDef -Name 'FullWidth' -Default $true)
                        $Border      = [bool](Get-MenuValue -Menu $MenuDef -Name 'Border' -Default $true)
                        $Title       = [string](Get-MenuValue -Menu $MenuDef -Name 'Title' -Default $null)
                        $StartRow    = [int](Get-MenuValue -Menu $MenuDef -Name 'StartRow' -Default 0)
                        $BorderInset = [int](Get-MenuValue -Menu $MenuDef -Name 'BorderInset' -Default 0)
                        $BorderType  = [string](Get-MenuValue -Menu $MenuDef -Name 'BorderType' -Default 'Curved')

                        $SelectionColor = Resolve-ColorCode -ColorCode (Get-MenuValue -Menu $MenuDef -Name 'SelectionColor' -Default 'Magenta') -ColorLocation 'Foreground'
                        $TextColor      = Resolve-ColorCode -ColorCode (Get-MenuValue -Menu $MenuDef -Name 'TextColor' -Default 'White') -ColorLocation 'Foreground'
                        $BorderColor    = Resolve-ColorCode -ColorCode (Get-MenuValue -Menu $MenuDef -Name 'BorderColor' -Default 'Blue') -ColorLocation 'Foreground'
                        $TitleColor     = Resolve-ColorCode -ColorCode (Get-MenuValue -Menu $MenuDef -Name 'TitleColor' -Default 'Blue') -ColorLocation 'Foreground'

                        $BackgroundMenu = [ConsoleFX.GridMenu]::New(
                            $Items,
                            $Columns,
                            $Padding,
                            $FullWidth,
                            $Border,
                            ($BorderType | ConvertTo-Case -From Pascal -To Snake),
                            $Title,
                            $StartRow,
                            $BorderInset,
                            $true
                        )
                        $BackgroundMenu.SetColor($SelectionColor, $TextColor, $BorderColor, $TitleColor)

                        If($Selections.ContainsKey($OriginalOrder))
                            {
                                $BackgroundMenu.SetSelectedItem([string]$Selections[$OriginalOrder])
                            }

                        $BackgroundMenu.UnderlayMenu = $Underlay
                        $Underlay = $BackgroundMenu
                    }

                $ActiveDef = $Ordered[$CurrentMenuPos].Menu
                $ActiveOriginalOrder = $Ordered[$CurrentMenuPos].OriginalOrder

                $ActiveItems       = @((Get-MenuValue -Menu $ActiveDef -Name 'Items' -Default @()))
                $ActiveColumns     = [int](Get-MenuValue -Menu $ActiveDef -Name 'Columns' -Default 3)
                $ActivePadding     = [int](Get-MenuValue -Menu $ActiveDef -Name 'Padding' -Default 1)
                $ActiveFullWidth   = [bool](Get-MenuValue -Menu $ActiveDef -Name 'FullWidth' -Default $true)
                $ActiveBorder      = [bool](Get-MenuValue -Menu $ActiveDef -Name 'Border' -Default $true)
                $ActiveTitle       = [string](Get-MenuValue -Menu $ActiveDef -Name 'Title' -Default $null)
                $ActiveStartRow    = [int](Get-MenuValue -Menu $ActiveDef -Name 'StartRow' -Default 0)
                $ActiveBorderInset = [int](Get-MenuValue -Menu $ActiveDef -Name 'BorderInset' -Default 0)
                $ActiveBorderType  = [string](Get-MenuValue -Menu $ActiveDef -Name 'BorderType' -Default 'Curved')

                $ActiveSelectionColor = Resolve-ColorCode -ColorCode (Get-MenuValue -Menu $ActiveDef -Name 'SelectionColor' -Default 'Magenta') -ColorLocation 'Foreground'
                $ActiveTextColor      = Resolve-ColorCode -ColorCode (Get-MenuValue -Menu $ActiveDef -Name 'TextColor' -Default 'White') -ColorLocation 'Foreground'
                $ActiveBorderColor    = Resolve-ColorCode -ColorCode (Get-MenuValue -Menu $ActiveDef -Name 'BorderColor' -Default 'Blue') -ColorLocation 'Foreground'
                $ActiveTitleColor     = Resolve-ColorCode -ColorCode (Get-MenuValue -Menu $ActiveDef -Name 'TitleColor' -Default 'Blue') -ColorLocation 'Foreground'

                $ActiveMenu = [ConsoleFX.GridMenu]::New(
                    $ActiveItems,
                    $ActiveColumns,
                    $ActivePadding,
                    $ActiveFullWidth,
                    $ActiveBorder,
                    ($ActiveBorderType | ConvertTo-Case -From Pascal -To Snake),
                    $ActiveTitle,
                    $ActiveStartRow,
                    $ActiveBorderInset,
                    $false
                )
                $ActiveMenu.SetColor($ActiveSelectionColor, $ActiveTextColor, $ActiveBorderColor, $ActiveTitleColor)
                $ActiveMenu.UnderlayMenu = $Underlay

                if ($Selections.ContainsKey($ActiveOriginalOrder))
                    {
                        $ActiveMenu.SetSelectedItem([string]$Selections[$ActiveOriginalOrder])
                    }

                $Result = $ActiveMenu.Run()

                if ($ActiveMenu.WasCancelled)
                    {
                        if ($NavigationStack.Count -gt 0)
                            {
                                $PreviousPosition = $NavigationStack[$NavigationStack.Count - 1]
                                $NavigationStack.RemoveAt($NavigationStack.Count - 1)
                                $CurrentMenuPos = $PreviousPosition
                                continue
                            }

                        continue
                    }

                $Selections[$ActiveOriginalOrder] = $Result
                $DefaultNextPosition = if (($CurrentMenuPos + 1) -lt $Ordered.Count) { [Nullable[int]]($CurrentMenuPos + 1) } else { [Nullable[int]]$null }
                $NextPosition = Resolve-NextMenuPosition -MenuDef $ActiveDef -SelectedItem $Result -CurrentPosition $CurrentMenuPos -DefaultNextPosition $DefaultNextPosition

                if ($null -eq $NextPosition)
                    {
                        break
                    }

                if ([int]$NextPosition -ne $CurrentMenuPos)
                    {
                        $NavigationStack.Add($CurrentMenuPos)
                    }

                $CurrentMenuPos = [int]$NextPosition
            }

        $OrderedSelections = @()
        For($k = 0; $k -lt $Ordered.Count; $k++)
            {
                $OriginalOrder = $Ordered[$k].OriginalOrder
                $OrderedSelections += $Selections[$OriginalOrder]
            }

        Return @{
            MenuOrder = @($Ordered | ForEach-Object { $_.Menu.Title })
            SelectionsByOriginalOrder = $Selections
            OrderedSelections = $OrderedSelections
        }
    }