TerminalUI.psm1
# This is a locally sourced Imports file for local development. # It can be imported by the psm1 in local development to add script level variables. # It will merged in the build process. This is for local development only. # region script variables # $script:resourcePath = "$PSScriptRoot\Resources" $MockableHost = $Host $MockableConsole = [System.Console] <# .EXTERNALHELP TerminalUI-help.xml #> function Get-UserChoice { param( [Parameter(Mandatory = $true)][object[]] $Items, [switch] $Multi, [switch] $NoHelp ) # abort early if we don't have any options to choose from if (-not $Items -or ($Items.Count -eq 0)) { if ($Multi) { return @() } return $null } # convert $Items to array of UserChoiceItem's $Items = @($Items | ForEach-Object { if ($_ -is [UserChoiceItem]) { return $_ } else { return New-UserChoiceItem ([string] $_) } }) # and setup rest of the state we use $Selection = @($Items | ForEach-Object { $false }) $Cancelled = $false $ActiveLine = 0 function Write-Menu { param( [int] $ActiveLine ) for ($i = 0; $i -lt $Items.length; ++$i) { $Color = [System.ConsoleColor]::Gray $Prefix = '>' if ($Multi -and $Selection[$i]) { $Prefix = '- [x]' } elseif ($Multi) { $Prefix = '- [ ]' } $Text = "$prefix $($Items[$i].Display)" if ($i -eq $ActiveLine) { $Color = [System.ConsoleColor]::DarkGreen } Write-Host -ForegroundColor $Color $Text } } # run through our menu drawing loop try { $MockableConsole::CursorVisible = $false # https://learn.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes $Key = 0 $VK_Return = 0x0D $VK_Esc = 0x1B $VK_Space = 0x20 $VK_Left = 0x25 $VK_Right = 0x27 $VK_Down = 0x28 $VK_Up = 0x26 if (-Not $NoHelp) { $Help = '[↑↓] Move' if ($Multi) { $Help += ' [Space] Toggle' $Help += ' [Enter] Submit (min 1 selection)' } else { $Help += ' [Enter/Space] Submit' } $Help += ' [Esc] Cancel' Write-Host -ForegroundColor DarkGray $Help } while ($Key -ne $VK_Esc) { if ($Key -eq $VK_Return) { $numChoices = ($Selection | Where-Object { $_ }).Count if (-not $Multi -or $numChoices -gt 0) { break } } Write-Menu $ActiveLine $MockableConsole::SetCursorPosition(0, $MockableConsole::CursorTop - $Items.Length) $Key = $MockableHost.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown").VirtualKeyCode switch ($Key) { $VK_Up { $ActiveLine = [math]::Max($ActiveLine - 1, 0) } $VK_Down { $ActiveLine = [math]::Min($ActiveLine + 1, $Items.Length - 1) } $VK_Left { $ActiveLine = 0 } $VK_Right { $ActiveLine = $Items.Length - 1 } $VK_Space { if ($Multi) { $Selection[$ActiveLine] = !$Selection[$ActiveLine] } else { $Key = $VK_Return } } $VK_Esc { $Cancelled = $true } } } } finally { $MockableConsole::SetCursorPosition(0, $MockableConsole::CursorTop + $Items.Length) $MockableConsole::CursorVisible = $true } if ($Cancelled) { return $null } elseif ($Multi) { $i = 0 return @($Items | Where-Object { $i++; return $Selection[$i - 1] } | ForEach-Object { $_.Value }) } else { return $Items[$ActiveLine].Value } } $MockableHost = $Host $MockableConsole = [System.Console] <# .EXTERNALHELP TerminalUI-help.xml #> function Get-UserInput { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Prompt', Justification = 'False positive')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'MaxSuggestions', Justification = 'False positive')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'MaskInput', Justification = 'False positive')] param( [string] $Prompt = '', [object[]] $Suggestions = @(), [int] $MaxSuggestions = 3, [switch] $NoHelp, [switch] $MaskInput ) # normalize suggestions into a list of [UserInputSuggestion]s $Suggestions = $Suggestions | ForEach-Object { if ($_ -is [UserInputSuggestion]) { return $_ } else { return New-UserInputSuggestion ([string] $_) } } if ($null -eq $Suggestions) { # the above can return null, because PowerShell $Suggestions = @() } # we use a state hashtable to keep track of the prompt $state = @{ Input = "" CursorIndex = 0 SuggestionIndex = 0 Suggestions = @() LastSuggestionsCount = 0 # used to erase previous list of suggestions Cancelled = $false } # writes the complete prompt to the CLI, including suggestions. Puts the cursor at the prompt afterwards function Write-Prompt { $esc = [char]27 $text = ""; $promptLen = $text.Length if ($Prompt) { $text += "$($PSStyle.Foreground.White)$($PSStyle.Bold)${Prompt}:$($PSStyle.Reset) " $promptLen += $Prompt.Length + 2 } if ($MaskInput) { $text += '*' * $state.Input.Length } else { $text += $state.Input } Write-Host "`r$text$esc[0K" # $esc[0K = erase from cursor to end of line Write-ClearSuggestions Write-Suggestions $MockableConsole::SetCursorPosition($promptLen + $state.CursorIndex, $MockableConsole::CursorTop) } # writes suggestions from the current line onwards. Puts the cursor at the start of the line it was at previously afterwards. function Write-Suggestions { $suggestionsPrefix = 'Suggestions: ' $suggestionsWindowStart = [math]::Max( 0, [math]::Min( $state.Suggestions.Length - $MaxSuggestions, $state.SuggestionIndex - 1 ) ); $shownSuggestions = $state.Suggestions[$suggestionsWindowStart..($suggestionsWindowStart + $MaxSuggestions - 1)] for ($i = 0; $i -lt $shownSuggestions.Length; ++$i) { $suggestion = $shownSuggestions[$i] $color = [System.ConsoleColor]::DarkGray $name = $suggestion.Display $text = '' if ($i -eq 0) { $text += $suggestionsPrefix $indent = 2 } else { $indent = "${suggestionsPrefix}> ".Length } if ($suggestion -eq $state.Suggestions[$state.SuggestionIndex]) { $indent = [math]::Max($indent - 2, 0) $name = "> $name" } $text += (' ' * $indent) + $name Write-Host -ForegroundColor $color $text } $MockableConsole::SetCursorPosition(0, $MockableConsole::CursorTop - $shownSuggestions.Length - 1) $state.LastSuggestionsCount = $shownSuggestions.Length } # clears any previously written suggestions from the host output. Puts the cursor at the start of the line it was at previously afterwards. function Write-ClearSuggestions { $esc = [char]27 for ($i = 0; $i -lt $state.LastSuggestionsCount; ++$i) { Write-Host "$esc[2K" } $MockableConsole::SetCursorPosition(0, $MockableConsole::CursorTop - $state.LastSuggestionsCount) } # sets the suggestions-related state to their appropriate values function Set-Suggestions { $state.Suggestions = @($Suggestions | Where-Object { $_.Value.StartsWith($state.Input) -and $_.Value -ne $state.Input }) $state.SuggestionIndex = [math]::Max(0, [math]::Min($state.Suggestions.Length - 1, $state.SuggestionIndex)) } # run through our prompt drawing loop try { # https://learn.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes $key = 0 $VK_Backspace = 0x08 $VK_Delete = 0x2E $VK_Tab = 0x09 $VK_End = 0x23 $VK_Home = 0x24 $VK_Return = 0x0D $VK_Esc = 0x1B $VK_Left = 0x25 $VK_Right = 0x27 $VK_Down = 0x28 $VK_Up = 0x26 if ($suggestions.Length -and -Not $NoHelp) { $help = '[↑↓] Change suggestion [Tab] Pick suggestion [Enter] Submit [Esc] Cancel' Write-Host -ForegroundColor DarkGray $help } while ($key.VirtualKeyCode -ne $VK_Return -and $state.Cancelled -eq $false) { Set-Suggestions $MockableConsole::CursorVisible = $false Write-Prompt $MockableConsole::CursorVisible = $true $key = $MockableHost.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") switch ($key.VirtualKeyCode) { $VK_Esc { $state.Cancelled = $true } $VK_Left { $state.CursorIndex = [math]::Max($state.CursorIndex - 1, 0) } $VK_Right { $state.CursorIndex = [math]::Min($state.CursorIndex + 1, $state.Input.Length) } $VK_Down { $state.SuggestionIndex = [math]::Min($state.Suggestions.Length - 1, $state.SuggestionIndex + 1) } $VK_Up { $state.SuggestionIndex = [math]::Max(0, $state.SuggestionIndex - 1) } $VK_End { $state.CursorIndex = $state.Input.Length } $VK_Home { $state.CursorIndex = 0 } $VK_Tab { if ($state.Suggestions.Length) { $state.Input = $state.Suggestions[$state.SuggestionIndex].Value $state.CursorIndex = $state.Input.Length } } $VK_Backspace { if ($state.CursorIndex -ne 0) { $state.Input = $state.Input.Remove($state.CursorIndex - 1, 1) $state.CursorIndex -= 1 } } $VK_Delete { if ($state.CursorIndex -ne $state.Input.Length) { $state.Input = $state.Input.Remove($state.CursorIndex, 1) } } $VK_Return {} Default { if ($key.Character -and $key.Character -ne "\u0000") { $state.Input += $key.Character $state.CursorIndex += $key.Character.Length } } } } } finally { $MockableConsole::CursorVisible = $true Write-Host "" Write-ClearSuggestions } if ($state.Cancelled) { return $null } return $state.Input } <# .EXTERNALHELP TerminalUI-help.xml #> function New-UserChoiceItem { param( [Parameter(Mandatory = $true)][object] $Value, [Parameter(Mandatory = $false)][string] $Display = $Value ) return [UserChoiceItem]::new($Value, $Display) } class UserChoiceItem { [object] $Value [string] $Display UserChoiceItem([object] $Value, [string] $Display) { $this.Value = $Value $this.Display = $Display } } <# .EXTERNALHELP TerminalUI-help.xml #> function New-UserInputSuggestion { param( [Parameter(Mandatory = $true)][string] $Value, [Parameter(Mandatory = $false)][string] $Display = $Value ) return [UserInputSuggestion]::new($Value, $Display) } class UserInputSuggestion { [string] $Value [string] $Display UserInputSuggestion([string] $Value, [string] $Display) { $this.Value = $Value $this.Display = $Display } } |