docs: TUI 与入口重组的设计记录(4 条 ADR + 验收锚点)
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 两版仍绿。
This commit is contained in:
1 parent
187549e2af
commit
60f2ae3e0f
25 files changed
+1708
No files matched your search
@@ -0,0 +1,200 @@
|
||||
# ============================================================================
|
||||
# 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 $_ } }
|
||||
Reference in new issue
Block a user