TableUI.psm1
# The overall width of the UI. [int]$UIWidth = 80 [int]$UIWidthMin = 54 # Frame buffer to mitigate re-draw flicker. [string[]]$FrameBuffer = @('') # Example of a custom script block $DummyScriptBlock = { param($currentSelections, $selectedIndex) Clear-Host Write-Output "The currently selected index is: $selectedIndex" Write-Output "`n[Press ENTER to return.]" [Console]::CursorVisible = $false $cursorPos = $host.UI.RawUI.CursorPosition while ($host.ui.RawUI.ReadKey().VirtualKeyCode -ne [ConsoleKey]::Enter) { $host.UI.RawUI.CursorPosition = $cursorPos [Console]::CursorVisible = $false } } <# .DESCRIPTION Shows a user-interface based on an array of objects. This interface allows a user to select zero or more items from this selection. By default, the provided reference is updated with an array of Booleans indicating which items in the array were selected. This format can be change to indicate the selected index or item values via the -SelectionFormat option. #> function Show-TableUI { [CmdletBinding()] param ( # The array of objects that will be presented in the table UI. [Parameter(Mandatory)] [PSCustomObject[]]$Table, # An array of Booleans indicating which items were selected. # IMPORTANT: This array will be set to $null if the user aborts the selection. [Parameter(Mandatory)] [ref]$Selections, # The title of the table, indicating what action will be performed after making the selections. [Parameter()] [string]$Title = 'Make Selections', # This is the member that will be displayed in the selection list. If not specified, the first NoteProperty member will be used. [Parameter()] [string]$DefaultMemberToShow, # These are the members to show when an item is currenlty selected. Order determines arrangement in UI. # If not specified, all (NoteProperty) members will be displayed. [Parameter()] [string[]]$SelectedItemMembersToShow = $null, # The decription of what the ENTER key does. Should be filled to 60-characters. [Parameter()] [string]$EnterKeyDescription = 'Press ENTER to show selection details.', # The script to execute whenn the ENNTER key is pressed. After completion, the screen will be redrawn by the TableUI. [Parameter()] [scriptblock]$EnterKeyScript = $DummyScriptBlock, # Specifies the format that the -Selections should be in. The default is an array of Booleans. [Parameter()] [ArgumentCompletions('Booleans', 'Indices', 'Items')] [string]$SelectionFormat = 'Booleans', # Specifies how the UI should be sized/fit in the window space. # 'Fill' will draw the UI to fill the viewable space (blank lines will be added at the end of the item selection subwindow to fill the vertical space). # 'FillWidth' will draw the UI to fill the width space (blank lines will not be added at the end of the item selection subwindow to fill the vertical space). # 'FitStandard' will use the standard 80 column width (blank lines will not be added at the end of the item selection subwindow to fill the vertical space). [Parameter()] [ArgumentCompletions('Fill', 'FillWidth', 'Standard')] [string]$UIFit = 'Fill' ) function Clear-Frame { $script:FrameBuffer = @('') } function Show-Frame { Clear-Host $script:FrameBuffer | ForEach-Object { Write-Output $_ } } function Write-FrameTopBar { param ( # The width of the overall UI. The content will take up $Width - 4. [int]$Width = $UIWidth ) $script:FrameBuffer += "┌$('─' * ($Width - 2))┐" } function Write-FrameMiddleBar { param ( # The width of the overall UI. The content will take up $Width - 4. [int]$Width = $UIWidth ) $script:FrameBuffer += "├$('─' * ($Width - 2))┤" } function Write-FrameBottomBar { param ( # The width of the overall UI. The content will take up $Width - 4. [int]$Width = $UIWidth ) $script:FrameBuffer += "└$('─' * ($Width - 2))┘" } function Write-FrameContent { param ( # The width of the overall UI. The content will take up $Width - 4. [int]$Width = $UIWidth, # The data to write to the current line. [string]$Content, # ANSI string that is responsible for setting the text styling for # the content. The frame/bars are not affected by this setting. [string]$AnsiiFormat = '' ) # Account for 4-characters consisting of leading and trailing pipe + space characters if ($Content.Length -gt ($Width - 4)) { # Truncate to fit width (account for additional ellipsis) $Content = "$($Content.Substring(0, $Width - 4 - 1))…" } else { # Pad the tail to fit $Width $Content = $Content + (' ' * (($Width - 4) - $Content.Length)) } if ([string]::IsNullOrWhiteSpace($AnsiiFormat)) { $script:FrameBuffer += "│ $Content │" } else { $script:FrameBuffer += "│$AnsiiFormat $Content $($PSStyle.Reset)│" } } <# .DESCRIPTION Write the frame data for the UI title bar. #> function Write-FrameTitle { param ( # The message to show. WIll be automatically truncated if it does # not fit within the contrains set by $UIWidth. [string]$Content, # ANSI string that is responsible for setting the text styling for # the content. The frame/bars are not affected by this setting. [string]$AnsiiFormat = '' ) Write-FrameTopBar if ([string]::IsNullOrWhiteSpace($AnsiiFormat)) { Write-FrameContent -Content $Content } else { Write-FrameContent -Content "$AnsiFormat$Content$($PSStyle.Reset)" } Write-FrameMiddleBar } <# .DESCRIPTION Write the frame data for the title of the selected item section. #> function Write-FrameSelectedItemTitle { param ( # The message to show. WIll be automatically truncated if it does # not fit within the contrains set by $Width. [string]$Content, # ANSI string that is responsible for setting the text styling for # the content. The frame/bars are not affected by this setting. [string]$AnsiiFormat = '' ) Write-FrameMiddleBar Write-FrameContent -Content $Content -AnsiiFormat $AnsiiFormat Write-FrameMiddleBar } <# .DESCRIPTION Write the frame data for the selectable items. #> function Write-FrameSelectionItems { param ( [string]$Title, [string[]]$SelectionItems, [int]$SelectionIndex, [bool[]]$Selections, [int]$WindowedSpan ) Write-FrameTitle -Content $Title for ($i = 0; $i -lt $SelectionItems.Count; $i++) { $selectedChar = " " if ($Selections[$i]) { $selectedChar = '•' } if ($i -eq $SelectionIndex) { $lineContent = "[$selectedChar] $($SelectionItems[$i])" Write-FrameContent -Content $lineContent -AnsiiFormat "$($PSStyle.Background.BrightBlue)$($PSStyle.Foreground.BrightWhite)" } else { $lineContent = " $selectedChar $($SelectionItems[$i])" Write-FrameContent -Content $lineContent } } if ($UIFit -eq 'Fill') { $padRows = $WindowedSpan - $SelectionItems.Count while ($padRows -gt 0) { Write-FrameContent -Content '' $padRows-- } } } <# .DESCRIPTION Write the frame data for the currently selected item. #> function Write-FrameSelectedItem { param ( [PSCustomObject[]]$SelectionItems, [int]$SelectionIndex, [string[]]$MembersToShow ) Write-FrameSelectedItemTitle -Content "Current Selection ($($selectionIndex+1) of $($SelectionItems.Count))" if ($null -eq $MembersToShow) { $MembersToShow = $SelectionItems[$SelectionIndex] | Get-Member -MemberType NoteProperty | ForEach-Object { $_.$DefaultMemberToShow } } $maxMemberName = ($MembersToShow | Measure-Object -Property Length -Maximum).Maximum + 1 # The special formatting characters result in additional non-printable characters that need to be accounted for. $ansiFormat = $PSStyle.Foreground.Green $ansiFormatAlt = $PSStyle.Foreground.BrightBlack $widthCorrection = $ansiFormat.Length + $PSStyle.Reset.Length $MembersToShow | ForEach-Object { if (-not([string]::IsNullOrWhiteSpace(($SelectionItems[$SelectionIndex].$_)))) { Write-FrameContent -Width ($UIWidth + $widthCorrection) -Content "$ansiFormat$_$(' ' * ($maxMemberName - $_.Length)): $($PSStyle.Reset)$($SelectionItems[$SelectionIndex].$_ -join ', ')" } else { Write-FrameContent -Width ($UIWidth + $widthCorrection) -Content "$ansiFormatAlt$_$(' ' * ($maxMemberName - $_.Length)): $($PSStyle.Reset)" } } Write-FrameBottomBar } <# .DESCRIPTION Gets the start index for the windows list view. #> function Get-WindowStartIndex { param ( [int]$WindowSpan, [int]$SelectionIndex, [int]$SelectionCount ) # Calculate the ideal start index to center the selection. $windowStartIndex = $SelectionIndex - [Math]::Floor($WindowSpan / 2) # Adjust the start index if it's near the start or end of the list. if ($windowStartIndex -lt 0) { $windowStartIndex = 0 } elseif ($windowStartIndex + $WindowSpan -gt $SelectionCount) { $windowStartIndex = $SelectionCount - $WindowSpan if ($windowStartIndex -lt 0) { $windowStartIndex = 0 } } return $windowStartIndex } <# .DESCRIPTION Wrapper to handle setting buffer width depending on OS. .OUTPUTS $True if the requested width failed, and should be rehandled in another call. #> function Set-BufferWidth { param ( [int]$Width ) $redraw = $False if ($IsWindows) { $ErrorActionPreferenceBackup = $ErrorActionPreference $ErrorActionPreference = 'SilentlyContinue' try { # This may fail if window is widened right before this statement # executes as the buffer width must always be at least the # window width. [Console]::BufferWidth = $Width } catch [System.Management.Automation.SetValueInvocationException] { # Ignore the error and tell the caller to retry after determining # whether the buffer width is still valid for the current window # width. $redraw = $True } finally { $ErrorActionPreference = $ErrorActionPreferenceBackup } } else { # While this is not equivalent to setting the buffer width, # it still appears to help eliminate unwanted flickering # when the width is smaller than the minimum width. stty cols $Width } return $redraw } <# .DESCRIPTION Write the frame data for the user controls. #> function Write-FrameControls { param ( # Decription should be filled to 60-characters. [string]$EnterKeyDescription, # When set, only the help key is shown [switch]$Minimize ) Write-FrameMiddleBar if ($Minimize) { Write-FrameContent -AnsiiFormat "$($PSStyle.Background.BrightBlack)" -Content "Press '?' to show the controls menu." } else { Write-FrameContent -AnsiiFormat "$($PSStyle.Background.BrightBlack)" -Content 'Press (PAGE) UP or (PAGE) DOWN to navigate selection.' Write-FrameContent -AnsiiFormat "$($PSStyle.Background.BrightBlack)" -Content $EnterKeyDescription Write-FrameContent -AnsiiFormat "$($PSStyle.Background.BrightBlack)" -Content 'Press SPACE to toggle selection.' Write-FrameContent -AnsiiFormat "$($PSStyle.Background.BrightBlack)" -Content "Press 'A' to select all, 'N' to select none." Write-FrameContent -AnsiiFormat "$($PSStyle.Background.BrightBlack)" -Content "Press 'C' to finish selections and continue operation." Write-FrameContent -AnsiiFormat "$($PSStyle.Background.BrightBlack)" -Content "Press '?' to minimize the controls menu." Write-FrameContent -AnsiiFormat "$($PSStyle.Background.BrightBlack)" -Content "Press ESC or 'Q' to quit now and cancel operation." } } $Selections.Value = $null $EnterKeyDescription = $EnterKeyDescription.TrimEnd() if ([string]::IsNullOrWhiteSpace($DefaultMemberToShow)) { $DefaultMemberToShow = ($Table | Select-Object -First 1 | Get-Member -MemberType NoteProperty | Select-Object -First 1).Name } $key = New-Object ConsoleKeyInfo [char]$currentKey = [char]0 [char]$selectAll ='a' [char]$selectNone ='n' [char]$continue = 'c' [char]$quitKey = 'q' [char]$helpKey = '?' [char]$helpKeyAlt = '/' $tempSelections = @($Table) | ForEach-Object { $false } [int]$selectionIndex = 0 [int]$windowStartIndex = 0 $helpMinimized = $false if ($null -eq $SelectedItemMembersToShow) { $SelectedItemMembersToShow = ($Table | Select-Object -First 1 | Get-Member -MemberType NoteProperty).Name } [Console]::TreatControlCAsInput = $true [int]$windowedSpan = $Host.UI.RawUI.WindowSize.Height - $numStandardMenuLines $redraw = $true $runLoop = $true while ($runLoop) { [int]$numStandardMenuLines = 17 + $SelectedItemMembersToShow.Count # Count is based on 'Frame' drawing calls below if ($helpMinimized) { $numStandardMenuLines -= 6 } $UIWidthLast = $UIWidth $windowedSpanLast = $windowedSpan $windowDimensions = $Host.UI.RawUI.WindowSize $windowedSpan = $windowDimensions.Height - $numStandardMenuLines if ($UIFit -eq 'Fill' -or $UIFit -eq 'FillWidth') { if ($windowDimensions.Width -ge $UIWidthMin) { $UIWidth = $windowDimensions.Width } else { $UIWidth = $UIWidthMin } } if ($windowedSpan -le 0) { $windowedSpan = 1 } if (($windowedSpanLast -ne $windowedSpan) -or ($UIWidthLast -ne $UIWidth) -or ([Console]::BufferWidth -ne $UIWidth)) { $redraw = $true } $windowStartIndex = Get-WindowStartIndex -WindowSpan $windowedSpan -SelectionCount $Table.Count -SelectionIndex $selectionIndex $windowedSelectionItems = @($Table.$DefaultMemberToShow)[$windowStartIndex..($windowStartIndex+$windowedSpan-1)] $windowedSelectionIndex = $selectionIndex - $windowStartIndex $windowedSelections = @($tempSelections)[$windowStartIndex..($windowStartIndex+$windowedSpan-1)] $numItemsToUpgrade = 0 $tempSelections | ForEach-Object { if ($_ -eq $true) { $numItemsToUpgrade++ } } $selectionMenuTitle = "$Title (Selected $($numItemsToUpgrade) of $($Table.Count))" if ($redraw) { $redraw = Set-BufferWidth -Width $UIWidth [Console]::CursorVisible = $false Clear-Frame Write-FrameSelectionItems -Title $selectionMenuTitle -SelectionItems $windowedSelectionItems -SelectionIndex $windowedSelectionIndex -Selections $windowedSelections -WindowedSpan $windowedSpan Write-FrameControls -EnterKeyDescription $EnterKeyDescription -Minimize:$helpMinimized Write-FrameSelectedItem -SelectionItems $Table -SelectionIndex $selectionIndex -MembersToShow $SelectedItemMembersToShow Show-Frame } if (-not([Console]::KeyAvailable)) { Start-Sleep -Milliseconds 10 continue } $redraw = $true $key = [Console]::ReadKey($true) $currentKey = [char]$key.Key switch ($currentKey) { # Navigate up { $_ -eq [ConsoleKey]::UpArrow } { if ($selectionIndex -gt 0) { $selectionIndex-- } } # Navigate down { $_ -eq [ConsoleKey]::DownArrow } { if ($selectionIndex -lt $Table.Count - 1) { $selectionIndex++ } } # Navigate up by one page { $_ -eq [ConsoleKey]::PageUp } { if ($selectionIndex - $windowedSpan -ge 0) { $selectionIndex -= $windowedSpan } else { $selectionIndex = 0 } } # Navigate down by one page { $_ -eq [ConsoleKey]::PageDown } { if ($selectionIndex + $windowedSpan -le $Table.Count - 1) { $selectionIndex += $windowedSpan } else { $selectionIndex = $Table.Count - 1 } } # Toggle selected item { $_ -eq [ConsoleKey]::Spacebar } { if ($tempSelections.Count -gt 1) { $tempSelections[$selectionIndex] = -not $tempSelections[$selectionIndex] } else { $tempSelections = -not $tempSelections } } # Toggle help { ($key.KeyChar -eq $helpKey) -or ($key.KeyChar -eq $helpKeyAlt) } { $helpMinimized = -not $helpMinimized } # Select all items $selectAll { $tempSelections = $tempSelections | ForEach-Object { $true } } # Deselect all items $selectNone { $tempSelections = $tempSelections | ForEach-Object { $false } } # Execute the ENTER script block for the selected item { $_ -eq [ConsoleKey]::Enter } { Invoke-Command -ScriptBlock $EnterKeyScript -ArgumentList @(@($tempSelections), $selectionIndex) } # Abort operation { ($_ -eq [ConsoleKey]::Escape) -or ($_ -eq $quitKey) -or ((($_ -eq $continue) -and ($key.Modifiers -contains [ConsoleModifiers]::Control))) } { Write-Output "`nAborted." $tempSelections = $null $runLoop = $false } { (($_ -eq $continue) -and ($key.Modifiers -notcontains [ConsoleModifiers]::Control)) } { $runLoop = $false } } } if ($null -eq $tempSelections) { return } $transformSelectionScript = $null switch ($SelectionFormat) { { $_ -eq 'Booleans' } { $Selections.Value = $tempSelections } { $_ -eq 'Indices' } { $transformSelectionScript = { param($index, $item, $selected) if ($selected) { $index } } } { $_ -eq 'Items' } { $transformSelectionScript = { param($index, $item, $selected) if ($selected) { $item } } } } if ($null -ne $transformSelectionScript) { $index = 0 $Selections.Value = $tempSelections | ForEach-Object { Invoke-Command -ScriptBlock $transformSelectionScript -ArgumentList $index, $tableData[$index], $_ $index++ } } } |