lib/ui.ps1
|
# WinMole Interactive UI Components # PwshSpectreConsole-based menus and prompts # Requires: lib/core.ps1 already dot-sourced # ── Hide/restore cursor (VT100 sequences) ──────────────────────────────────── $script:_ESC = [char]27 function Hide-Cursor { Write-Host "$($script:_ESC)[?25l" -NoNewline } function Show-Cursor { Write-Host "$($script:_ESC)[?25h" -NoNewline } function Write-InputTrace { param([string]$Message) if (-not $env:WINMOLE_TRACE_INPUT) { return } $stamp = (Get-Date).ToString('o') Add-Content -Path $env:WINMOLE_TRACE_INPUT -Value "$stamp $Message" } function Test-KeyAvailable { try { return [Console]::KeyAvailable } catch { return $false } } function Read-ConsoleKeyInfo { try { return $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown,AllowCtrlC') } catch { return [Console]::ReadKey($true) } } function Get-KeyCharacter { param([object]$KeyInfo) $charValue = Get-OptionalPropertyValue -InputObject $KeyInfo -Name 'KeyChar' if ($null -eq $charValue) { $charValue = Get-OptionalPropertyValue -InputObject $KeyInfo -Name 'Character' } if ($null -eq $charValue) { return '' } $character = [char]$charValue if ([int]$character -eq 0) { return '' } if ([int]$character -lt 32) { return '' } return [string]$character } function Resolve-KeyName { param([object]$KeyInfo) $keyName = [string](Get-OptionalPropertyValue -InputObject $KeyInfo -Name 'Key' -Default '') $modifiers = Get-OptionalPropertyValue -InputObject $KeyInfo -Name 'Modifiers' $controlState = [string](Get-OptionalPropertyValue -InputObject $KeyInfo -Name 'ControlKeyState' -Default '') switch ($keyName) { 'UpArrow' { return 'Up' } 'DownArrow' { return 'Down' } 'LeftArrow' { return 'Left' } 'RightArrow' { return 'Right' } 'Q' { return 'Quit' } 'Enter' { return 'Enter' } 'Spacebar' { return 'Space' } 'Delete' { return 'Delete' } 'Backspace' { return 'Backspace' } 'Home' { return 'Home' } 'End' { return 'End' } 'PageUp' { return 'PageUp' } 'PageDown' { return 'PageDown' } 'Escape' { return 'Escape' } 'Tab' { return 'Tab' } } $virtualKeyCode = Get-OptionalPropertyValue -InputObject $KeyInfo -Name 'VirtualKeyCode' $isCtrlModified = $false if ($null -ne $modifiers -and ($modifiers.ToString() -match 'Control')) { $isCtrlModified = $true } if (-not $isCtrlModified -and $controlState -match 'Ctrl') { $isCtrlModified = $true } if ($isCtrlModified -and ($keyName -eq 'C' -or [int]$virtualKeyCode -eq 67)) { return 'Quit' } if ($null -ne $virtualKeyCode) { switch ([int]$virtualKeyCode) { 38 { return 'Up' } 40 { return 'Down' } 37 { return 'Left' } 39 { return 'Right' } 81 { return 'Quit' } 13 { return 'Enter' } 32 { return 'Space' } 46 { return 'Delete' } 8 { return 'Backspace' } 36 { return 'Home' } 35 { return 'End' } 33 { return 'PageUp' } 34 { return 'PageDown' } 27 { return 'Escape' } 9 { return 'Tab' } } } $c = Get-KeyCharacter -KeyInfo $KeyInfo switch ($c) { 'j' { return 'Down' } 'k' { return 'Up' } 'h' { return 'Left' } 'l' { return 'Right' } 'q' { return 'Quit' } 'Q' { return 'Quit' } default { return $c } } } function Clear-PendingKeys { while (Test-KeyAvailable) { [void](Read-ConsoleKeyInfo) } } function Format-AnalyzeEntryLine { param( [string]$Pointer, [string]$Rank, [string]$Bar, [double]$Percent, [string]$Icon, [string]$Name, [string]$SizeText, [string]$AgeText = '' ) $pctText = ('{0,5:F1}%' -f $Percent) return " $Pointer $Rank $Bar $pctText | [grey]$(Esc $Icon)[/] [white]$(Esc $Name)[/] [gold1]$SizeText[/]$AgeText" } # ── Read a single keypress (arrow-key aware) ────────────────────────────────── # Returns: 'Up','Down','Left','Right','Enter','Space','Delete','Backspace', # 'Home','End','PageUp','PageDown','Escape','Quit','Tab', or the char itself. function Read-Key { $keyInfo = Read-ConsoleKeyInfo $resolved = Resolve-KeyName -KeyInfo $keyInfo $keyName = [string](Get-OptionalPropertyValue -InputObject $keyInfo -Name 'Key' -Default '') $virtualKeyCode = Get-OptionalPropertyValue -InputObject $keyInfo -Name 'VirtualKeyCode' -Default '' $char = Get-KeyCharacter -KeyInfo $keyInfo $character = Get-OptionalPropertyValue -InputObject $keyInfo -Name 'Character' -Default '' $keyChar = Get-OptionalPropertyValue -InputObject $keyInfo -Name 'KeyChar' -Default '' $controlState = [string](Get-OptionalPropertyValue -InputObject $keyInfo -Name 'ControlKeyState' -Default '') $modifiers = [string](Get-OptionalPropertyValue -InputObject $keyInfo -Name 'Modifiers' -Default '') Write-InputTrace "resolved=[$resolved] key=[$keyName] vk=[$virtualKeyCode] char=[$char] rawChar=[$character] keyChar=[$keyChar] ctrl=[$controlState] modifiers=[$modifiers] type=[$($keyInfo.GetType().FullName)]" return $resolved } # ── Multi-select checklist menu ─────────────────────────────────────────────── # Items: array of pscustomobject with .Label, .Size (optional), .Tag (optional), # .Selected (bool), .Disabled (bool, optional) # Returns: the items array with updated .Selected values, or $null if cancelled. function Resolve-ChecklistEnterAction { param( [object[]]$Items, [int]$Cursor ) $selectedCount = @( $Items | Where-Object { [bool](Get-OptionalPropertyValue -InputObject $_ -Name 'Selected' -Default $false) } ).Count if ($selectedCount -gt 0) { return 'Confirm' } if ($Cursor -lt 0 -or $Cursor -ge $Items.Count) { return 'NoOp' } $item = $Items[$Cursor] if ([bool](Get-OptionalPropertyValue -InputObject $item -Name 'Disabled' -Default $false)) { return 'NoOp' } $item.Selected = $true return 'Toggle' } function Show-ChecklistMenu { param( [string]$Title, [object[]]$Items, [int]$MaxVisible = 18 ) if ($Items.Count -eq 0) { return $Items } $cursor = 0 $offset = 0 $pageSize = [Math]::Max(5, $MaxVisible) $startupGuardUntil = (Get-Date).AddMilliseconds(750) $hasUserInteraction = $false Hide-Cursor try { Clear-PendingKeys while ($true) { [Console]::Clear() Write-Host "" Write-SpectreHost " [bold deepskyblue1]$(Esc $Title)[/]" Write-Host "" $end = [Math]::Min($offset + $pageSize, $Items.Count) for ($i = $offset; $i -lt $end; $i++) { $item = $Items[$i] $selected = [bool](Get-OptionalPropertyValue -InputObject $item -Name 'Selected' -Default $false) $disabled = [bool](Get-OptionalPropertyValue -InputObject $item -Name 'Disabled' -Default $false) $size = Get-OptionalPropertyValue -InputObject $item -Name 'Size' -Default 0 $tag = Get-OptionalPropertyValue -InputObject $item -Name 'Tag' -Default '' $pointer = if ($i -eq $cursor) { '[deepskyblue1]▶[/]' } else { ' ' } $check = if ($selected) { '[green]■[/]' } else { '[grey]□[/]' } $line = " $pointer $check [white]$(Esc $item.Label)[/]" if ([long]$size -gt 0) { $line += " [gold1]$(Format-Bytes $size)[/]" } if ($tag) { $line += " [grey]| $(Esc $tag)[/]" } if ($disabled) { $line += " [grey](disabled)[/]" } Write-SpectreHost $line } Write-Host "" Write-SpectreHost " [grey]↑↓ Navigate Space Toggle Enter Select/Confirm Esc Cancel[/]" $key = Read-Key if (-not $key) { continue } if (-not $hasUserInteraction -and (Get-Date) -lt $startupGuardUntil -and $key -in @('Enter', 'Escape')) { continue } switch ($key) { 'Up' { $hasUserInteraction = $true if ($cursor -gt 0) { $cursor-- if ($cursor -lt $offset) { $offset = $cursor } } } 'Down' { $hasUserInteraction = $true if ($cursor -lt ($Items.Count - 1)) { $cursor++ if ($cursor -ge ($offset + $pageSize)) { $offset++ } } } 'Home' { $hasUserInteraction = $true $cursor = 0 $offset = 0 } 'End' { $hasUserInteraction = $true $cursor = $Items.Count - 1 $offset = [Math]::Max(0, $Items.Count - $pageSize) } 'PageUp' { $hasUserInteraction = $true $cursor = [Math]::Max(0, $cursor - $pageSize) $offset = [Math]::Max(0, $offset - $pageSize) } 'PageDown' { $hasUserInteraction = $true $cursor = [Math]::Min($Items.Count - 1, $cursor + $pageSize) $offset = [Math]::Min([Math]::Max(0, $Items.Count - $pageSize), $offset + $pageSize) } 'Space' { $hasUserInteraction = $true $item = $Items[$cursor] $disabled = [bool](Get-OptionalPropertyValue -InputObject $item -Name 'Disabled' -Default $false) if (-not $disabled) { $item.Selected = -not [bool](Get-OptionalPropertyValue -InputObject $item -Name 'Selected' -Default $false) } } 'Enter' { $action = Resolve-ChecklistEnterAction -Items $Items -Cursor $cursor switch ($action) { 'Confirm' { return $Items } 'Toggle' { $hasUserInteraction = $true } } } 'Escape' { return $null } 'Quit' { return $null } } } } finally { Show-Cursor } } # ── Simple single-select menu ───────────────────────────────────────────────── function Show-SelectMenu { param( [string]$Title, [string[]]$Options, [int]$MaxVisible = 20 ) try { $selected = $Options | Read-SpectreSelection -Title $Title -PageSize $MaxVisible } catch { return -1 } if (-not $selected) { return -1 } return [Array]::IndexOf($Options, $selected) } |