整个 TUI 的地基:位置、对齐、边框都靠它。而它算错了**不会报错** —— 表现只是菜单右边框歪一点、光标偏一列,所以每条边界都在断言里钉住。 为什么必须自己算:.Length 是 UTF-16 code unit 的个数(汉字 1 个 char、2 列);而 $Host.UI.RawUI.LengthInBufferCells 在 PowerShell 7 上对、在 5.1 上错(实测 中文 返回 2、字体边框字符 返回 6),且不抛错 —— 这种"一边对一边错"的 API 比两边都错更危险。 表里三个边界是实测踩过的:半角片假名 U+FF61–FF9F 是 1 列(不能划进全角区);全角拉丁 U+FF00–FF60 是 2 列;emoji 是代理对,按一个码点算。 验收:断言 8 条(含空串与控制字符);test.ps1 9/9 全绿(5.1 与 7)。
201 lines
9.2 KiB
PowerShell
201 lines
9.2 KiB
PowerShell
# ============================================================================
|
|
# 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 $_ } }
|