# ============================================================================ # ZERO-DEPENDENCY PowerShell TUI PROTOTYPE (works on 5.1 and 7.x) # ASCII-only source so 5.1 (which reads non-BOM files as ANSI) parses it. # ============================================================================ param([string]$OutFile, [switch]$Headless) $script:Log = New-Object System.Collections.Generic.List[string] function Note($s) { $script:Log.Add($s) } # --------------------------------------------------------------------------- # 1. CAPABILITY DETECTION -- never assume VT, never assume a console # --------------------------------------------------------------------------- $script:HasRealConsole = $false try { $script:HasRealConsole = -not [Console]::IsOutputRedirected } catch { $script:HasRealConsole = $false } $script:HasVT = $false if ($script:HasRealConsole) { try { $script:HasVT = [bool]$Host.UI.SupportsVirtualTerminal } catch { $script:HasVT = $false } } $script:Cols = 80; $script:Rows = 24 function Get-ConsoleSize { $w = 0; $h = 0 try { $w = [Console]::WindowWidth; $h = [Console]::WindowHeight } catch { } if ($w -le 0 -or $h -le 0) { try { $s = $Host.UI.RawUI.WindowSize; $w = $s.Width; $h = $s.Height } catch { } } if ($w -le 0) { $w = 80 } if ($h -le 0) { $h = 24 } return @($w, $h) } # --------------------------------------------------------------------------- # 2. DISPLAY WIDTH -- MUST be computed by hand. Measured facts: # * ".Length" counts UTF-16 code units, NOT columns (CJK = 1 unit, 2 cols) # * RawUI.LengthInBufferCells() is UNRELIABLE and INCONSISTENT: # - 5.1: returns the char COUNT for CJK, and 2 for box-drawing chars # - 7.x: returns 2 for CJK, 1 for box-drawing (correct) # * Measure-Object -Character == .Length (useless for layout) # So: own East-Asian-Wide table. # --------------------------------------------------------------------------- $script:WideRanges = @( @(0x1100, 0x115F), @(0x2E80, 0x303E), @(0x3041, 0x33FF), @(0x3400, 0x4DBF), @(0x4E00, 0x9FFF), @(0xA000, 0xA4CF), @(0xAC00, 0xD7A3), @(0xF900, 0xFAFF), @(0xFE30, 0xFE6F), @(0xFF00, 0xFF60), @(0xFFE0, 0xFFE6), @(0x1F300, 0x1F64F), @(0x1F900, 0x1F9FF), @(0x20000, 0x2FFFD), @(0x30000, 0x3FFFD) ) function Get-CellWidth { param([string]$Text) $w = 0 $i = 0 while ($i -lt $Text.Length) { $cp = [int][char]$Text[$i] if ($cp -ge 0xD800 -and $cp -le 0xDBFF -and ($i + 1) -lt $Text.Length) { $lo = [int][char]$Text[$i + 1] if ($lo -ge 0xDC00 -and $lo -le 0xDFFF) { $cp = 0x10000 + (($cp - 0xD800) * 0x400) + ($lo - 0xDC00) $i++ } } $cw = 1 foreach ($r in $script:WideRanges) { if ($cp -ge $r[0] -and $cp -le $r[1]) { $cw = 2; break } } if ($cp -lt 0x20) { $cw = 0 } if ($cp -eq 0x7F) { $cw = 0 } $w += $cw $i++ } return $w } function Pad-ToWidth { param([string]$Text, [int]$Width) $cur = Get-CellWidth $Text if ($cur -ge $Width) { return $Text } return $Text + (' ' * ($Width - $cur)) } # --------------------------------------------------------------------------- # 3. LOW-LEVEL DRAW PRIMITIVES # --------------------------------------------------------------------------- function Move-To { param([int]$X, [int]$Y) if ($script:HasRealConsole) { try { [Console]::SetCursorPosition($X, $Y); return } catch { } } if ($script:HasVT) { [Console]::Out.Write("$([char]27)[$($Y + 1);$($X + 1)H"); [Console]::Out.Flush() } } function Write-At { param([int]$X, [int]$Y, [string]$Text, [string]$Color) Move-To $X $Y $useAnsi = ($script:HasVT) -and ((Get-CellWidth $Text) -eq 0 -or $Text -notmatch "[$([char]27)]") if ($Color -and $useAnsi) { $code = switch ($Color) { 'Red' { 91 } 'Green' { 92 } 'Yellow' { 93 } 'Cyan' { 96 } 'Gray' { 90 } default { 0 } } if ($code) { Write-Host -NoNewline "$([char]27)[${code}m$Text$([char]27)[0m"; return } } if ($Color) { Write-Host -NoNewline -ForegroundColor $Color $Text } else { Write-Host -NoNewline $Text } } function New-Frame { param([string]$Title, [int]$X, [int]$Y, [int]$W, [int]$H) $tl = [char]0x250C; $tr = [char]0x2510; $bl = [char]0x2514; $br = [char]0x2518 $hz = [char]0x2500; $vt = [char]0x2502 Write-At $X $Y ($tl + ($hz.ToString() * ($W - 2)) + $tr) 'Cyan' for ($r = 1; $r -lt ($H - 1); $r++) { Write-At $X ($Y + $r) $vt 'Cyan' Write-At ($X + $W - 1) ($Y + $r) $vt 'Cyan' } Write-At $X ($Y + $H - 1) ($bl + ($hz.ToString() * ($W - 2)) + $br) 'Cyan' if ($Title) { Write-At ($X + 2) $Y (" $Title ") 'Yellow' } } # --------------------------------------------------------------------------- # 4. MENU with multi-select: own key loop via RawUI.ReadKey # --------------------------------------------------------------------------- function Read-KeyRaw { # Returns @{ Key; Char } -- 'NoEcho' keeps the console clean try { $k = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') return @{ Key = $k.VirtualKeyCode; Char = $k.Character } } catch { Note ("ReadKey unavailable: " + $_.Exception.Message) return $null } } function Show-Menu { param([string[]]$Items, [string[]]$Checked, [string]$Title) $sel = 0; $top = 0; $visible = 10 while ($true) { # ---- render ---- Write-Host "" New-Frame $Title 0 0 60 ($visible + 4) for ($i = 0; $i -lt [Math]::Min($visible, $Items.Count); $i++) { $idx = $top + $i $mark = if ($Checked[$idx] -eq 'x') { '[x]' } else { '[ ]' } $line = "$mark $($Items[$idx])" $col = if ($idx -eq $sel) { 'Yellow' } else { 'Gray' } Write-At 2 ($i + 2) (Pad-ToWidth $line (56)) $col } Write-At 2 ($visible + 2) (Pad-ToWidth 'Space=toggle Enter=confirm Esc=cancel Up/Down=move' 56) 'Gray' $k = Read-KeyRaw if (-not $k) { Note 'No raw key input available -> returning current state.'; break } switch ($k.Key) { 38 { if ($sel -gt 0) { $sel--; if ($sel -lt $top) { $top-- } } } # Up 40 { if ($sel -lt $Items.Count - 1) { $sel++; if ($sel -ge ($top + $visible)) { $top++ } } } # Down 32 { if ($Checked[$sel] -eq 'x') { $Checked[$sel] = ' ' } else { $Checked[$sel] = 'x' } } # Space 13 { return $Checked } # Enter 27 { return $null } # Esc } } return $Checked } # --------------------------------------------------------------------------- # 5. PROGRESS -- two strategies, pick by capability # --------------------------------------------------------------------------- function Show-Progress { param([int]$Percent, [string]$Label, [int]$Y) $width = 40 $filled = [int]([Math]::Round($width * $Percent / 100.0)) $bar = ('#' * $filled) + ('.' * ($width - $filled)) Write-At 2 $Y (Pad-ToWidth ("$Label [$bar] $Percent%") 76) 'Cyan' } function Show-Spinner { param([int]$Frame, [string]$Label, [int]$Y) $s = @('|', '/', '-', '\')[$Frame % 4] Write-At 2 $Y (Pad-ToWidth "$s $Label" 76) 'Yellow' } # --------------------------------------------------------------------------- # RUN # --------------------------------------------------------------------------- Note ("PSVersion : " + $PSVersionTable.PSVersion) Note ("PSEdition : " + $PSVersionTable.PSEdition) Note ("HasRealConsole : " + $script:HasRealConsole) Note ("HasVT : " + $script:HasVT) $sz = Get-ConsoleSize Note ("Console size : " + $sz[0] + "x" + $sz[1]) Note ("ReadKey class : " + [System.Management.Automation.Host.KeyInfo].FullName) Note ("Measure width demo: 'AB' -> " + (Get-CellWidth 'AB') + " cells, 2 CJK chars -> " + (Get-CellWidth ([string]::Concat([char]0x4E2D, [char]0x6587))) + " cells") Note ("Pad-ToWidth('中文', 10) length = " + (Pad-ToWidth ([string]::Concat([char]0x4E2D, [char]0x6587)) 10).Length + " chars / " + (Get-CellWidth (Pad-ToWidth ([string]::Concat([char]0x4E2D, [char]0x6587)) 10)) + " cells") if ($Headless -or (-not $script:HasRealConsole)) { Note 'HEADLESS MODE: skipping interactive menu + drawing (no console attached).' Note 'This is exactly the branch a scheduled task must take.' } else { try { [Console]::CursorVisible = $false } catch { } try { [Console]::Clear() } catch { } Show-Progress 0 'Probe' 12 for ($p = 10; $p -le 100; $p += 30) { Show-Progress $p 'Probe' 12; Start-Sleep -Milliseconds 250 } $items = @('alpha-backup', 'beta-backup', 'gamma-backup', 'delta-backup', 'epsilon-backup') $checked = @('x', ' ', ' ', ' ', ' ') $res = Show-Menu -Items $items -Checked $checked -Title 'Select backup sets' if ($res) { Note ('USER SELECTED: ' + (($items | Where-Object { $res[$items.IndexOf($_)] -eq 'x' }) -join ', ')) } else { Note 'USER CANCELLED' } try { [Console]::Clear() } catch { } try { [Console]::CursorVisible = $true } catch { } } if ($OutFile) { [System.IO.File]::WriteAllLines($OutFile, $script:Log, (New-Object System.Text.UTF8Encoding($false))) } else { $script:Log | ForEach-Object { Write-Host $_ } }