grilling 三轮把设计树走完了,这里把它落成文件,避免决策只活在对话里。 ADR-0010 零依赖自研 TUI:三个候选库全部实测排除 —— ConsoleGuiTools 的 PSGallery 元数据声明最低 7.2(5.1 装不上);Spectre.Console 0.49.1 能在 5.1 加载但要塞 4 个第三方 DLL 且不支持鼠标;Terminal.Gui 1.15.0 能反射接线到真的渲染出窗口,但 Application.Shutdown() 在两端都抛 NRE。同时记下两条实测坑:无控制台时 $Host.UI.SupportsVirtualTerminal 会撒谎返回 True(第一道闸门必须是 [Console]::IsOutputRedirected),以及 5.1 的 RawUI.LengthInBufferCells 对中文返回 2、对边框字符返回 6(都不能用来排版)。 ADR-0011 进度回调这个例外:它是注入点不是状态,无头路径为 $null 时行为与今天完全一致。 ADR-0012 入口改名与垫片:这次留垫片而 Common.psm1 直接删,是因为前者是外部接口(断了是静默没用)后者是内部实现(断了当场报错)。并纠正了自己的一个伪前提 —— 本机实测没有注册任何 BakNRet 计划任务。 ADR-0013 TUI 写配置:外科式改写 + 校验通过才原子替换。硬事实是 SoftwareCatalog.psd1 根本不能被 Import-PowerShellDataFile 读入(动态表达式),任何"解析成对象再序列化"的方案都会毁掉表达式与注释。 .scratch/tui/spec.md 是这次改造的验收锚点:9 条已定决策 + 三轮的判据 + 风险表。 验收:Parse 两版仍绿。
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 $_ } }
|