feat(tui): 菜单渲染与循环(第一轮第四块·下)

形态是行内(ADR-0010):菜单按普通行往下打,之后每次按键只把那几行原地重画(记下起始行再定位回去)。不做全屏、不切备用缓冲区 —— 崩了的时候滚屏里还留着上文,而"终端被 TUI 弄乱"是这类代码最难排查的故障。

没有控制台时只打一次、不重画、不抛错,靠 -Driver 里的 -InputScript 驱动 —— 同一个循环在真终端与门禁里走**同一条代码路径**,断言看的是返回值(Action/Index/Chosen/Checked),不需要捕获控制台输出。

两处刻意的不猜:空菜单直接返回 cancel 并说明原因(没有可选项时不该等按键 —— 第一次使用时归档目录就是空的,在计划任务里等按键等于挂起);绝不 exit、绝不碰退出码(TUI 的异常不该污染备份结果)。

这块的断言按**数量分档**写(0 项 / 1 项 / 3 项)——上一块的单键 Bug 正是藏在"1 项"这个档位里,而当时只跑了 3 项的序列。

验收:断言 10 条;test.ps1 9/9 全绿(5.1 与 7);真实清单只读冒烟 4/4。
This commit is contained in:
Shuery committed 2026-09-27 16:13:57 +08:00
1 parent 6315c72f69
commit 835fb0cf2b
4 files changed
+129

No files matched your search

+1
View File
@@ -19,6 +19,7 @@
CompatiblePSEditions = @('Desktop', 'Core')
FunctionsToExport = @(
'Invoke-BakNRetMenu',
'Update-BakNRetMenuState',
'Format-BakNRetPaddedText',
'Write-BakNRetAt',
+3
View File
@@ -155,7 +155,10 @@
. (Join-Path $PSScriptRoot 'Public\Update-BakNRetMenuState.ps1')
. (Join-Path $PSScriptRoot 'Public\Invoke-BakNRetMenu.ps1')
Export-ModuleMember -Function @(
'Invoke-BakNRetMenu',
'Update-BakNRetMenuState',
'Format-BakNRetPaddedText',
'Write-BakNRetAt',
+92
View File
@@ -0,0 +1,92 @@
function Invoke-BakNRetMenu {
<#
.SYNOPSIS
画一个行内菜单并把按键循环跑到底,返回用户选了什么。
.DESCRIPTION
形态是**行内**(ADR-0010):菜单按普通行往下打,之后每次按键只把那几行**原地重画**
(记下起始行,再 SetCursorPosition 回去)。不做全屏、不切备用缓冲区 —— 崩了的时候滚屏里
还留着上文,而"终端被 TUI 弄乱"是这类代码最难排查的故障。
没有控制台(重定向 / 计划任务 / 门禁)时:只打一次菜单、不重画、不抛错,靠 -Driver 里的
`-InputScript` 把按键喂进来。这样同一个循环在真终端和门禁里是**同一条代码路径**。
**绝不 exit、绝不写退出码**:TUI 的异常不该污染备份结果(ADR-0010)。这个函数只返回值,
退出码永远属于 `$failed` 语义。
空菜单直接返回 cancel 并说明原因 —— 没有可选项时不该等按键(第一次使用时归档目录就是空的,
等按键在计划任务里等于挂起)。
#>
param(
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Title,
[Parameter(Mandatory = $true)]$Items,
[Parameter(Mandatory = $true)]$Driver,
[bool]$MultiSelect = $true,
[int]$InitialIndex = 0,
[string[]]$CheckedKeys = @(),
[string]$Hint = '↑↓ 选择 空格 勾选 回车 确认 Esc 返回'
)
$itemList = @($Items | Where-Object { $_ })
if ($itemList.Count -eq 0) {
Write-BakNRetAt -X 0 -Y 0 -Text ("{0}:没有可选项。" -f $Title) -ForegroundColor Yellow
return [pscustomobject]@{ Action = 'cancel'; Index = 0; Checked = @{}; Chosen = $null; Reason = 'empty' }
}
# 有控制台才重画;没有就只打一次(重定向下重画毫无意义,还会把日志弄花)
$canRedraw = -not [Console]::IsOutputRedirected
$blockRow = 0
$checked = @{}
foreach ($k in @($CheckedKeys)) { if ($k) { $checked[$k] = $true } }
$state = [pscustomobject]@{ Index = $InitialIndex; Checked = $checked; Action = 'none' }
$consoleWidth = 80
if ($canRedraw) { try { $consoleWidth = [Console]::WindowWidth } catch { } }
while ($true) {
if ($canRedraw) {
try {
if ($blockRow -gt 0) { [Console]::SetCursorPosition(0, $blockRow) }
}
catch {
# 定位失败就退化成"每次往下打一遍",难看好过失败
$canRedraw = $false
}
}
Write-BakNRetAt -X 0 -Y 0 -Text $Title -ForegroundColor Cyan
if ($canRedraw -and $blockRow -eq 0) { $blockRow = [Console]::CursorTop }
for ($i = 0; $i -lt $itemList.Count; $i++) {
$item = $itemList[$i]
$cursor = if ($i -eq $state.Index) { '>' } else { ' ' }
$box = ''
if ($MultiSelect) {
$box = if ($state.Checked.ContainsKey([string]$item.Key)) { '[x] ' } else { '[ ] ' }
}
$line = ' {0} {1}{2}' -f $cursor, $box, $item.Text
# 按列宽裁剪:超长条目不能把边框/后续内容挤走,也不能把汉字劈成半个
$line = Format-BakNRetPaddedText -Text $line -Width ([Math]::Max(10, $consoleWidth - 1))
if ($i -eq $state.Index) { Write-BakNRetAt -X 0 -Y 0 -Text $line -ForegroundColor White }
else { Write-BakNRetAt -X 0 -Y 0 -Text $line }
}
Write-BakNRetAt -X 0 -Y 0 -Text $Hint -ForegroundColor DarkGray
$key = Read-BakNRetKey -Driver $Driver
$next = Update-BakNRetMenuState -Items $itemList -Index $state.Index -Checked $state.Checked -MultiSelect $MultiSelect -Key $key
$state = [pscustomobject]@{ Index = $next.Index; Checked = $next.Checked; Action = $next.Action }
if ($state.Action -ne 'none') { break }
}
$chosen = if ($state.Action -eq 'confirm') { $itemList[$state.Index] } else { $null }
return [pscustomobject]@{
Action = $state.Action
Index = $state.Index
Checked = $state.Checked
Chosen = $chosen
Reason = ''
}
}
+33
View File
@@ -1523,6 +1523,39 @@ Test-Case '菜单状态机:绕回、空格切换、确认/取消、空菜单
Test-Case '菜单循环:按数量分档验(0 项 / 1 项 / 3 项),单键与多键序列都能走完' {
$items = @(
[pscustomobject]@{ Key = 'a'; Text = '第一个' },
[pscustomobject]@{ Key = 'b'; Text = '第二个' },
[pscustomobject]@{ Key = 'c'; Text = '第三个' }
)
# 按数量分档是踩出来的:单键序列曾经因为"单元素数组被拆成字符串"而变成首字母,
# 而只跑多键序列的断言看不见它。所以 1 项的档位与 0 项的档位各自都要有断言。
$esc = Invoke-BakNRetMenu -Title '测试' -Items $items -Driver (New-BakNRetInputDriver -Script @('Esc'))
Assert-Equal 'cancel' $esc.Action '单键 Esc 应当直接取消'
Assert-Equal 'confirm' (Invoke-BakNRetMenu -Title '测试' -Items $items -Driver (New-BakNRetInputDriver -Script @('Enter'))).Action '单键回车应当确认'
# 1 项:上下键都停在 0,回车仍能确认
$oneItem = @([pscustomobject]@{ Key = 'only'; Text = '只有一项' })
$single = Invoke-BakNRetMenu -Title '测试' -Items $oneItem -Driver (New-BakNRetInputDriver -Script @('Up', 'Enter'))
Assert-Equal 'confirm' $single.Action '只有一项时也能确认'
Assert-Equal 0 $single.Index '只有一项时按上键仍停在 0'
# 3 项:下移 + 勾选 + 确认
$result = Invoke-BakNRetMenu -Title '测试' -Items $items -Driver (New-BakNRetInputDriver -Script @('Down', 'Space', 'Enter'))
Assert-Equal 'confirm' $result.Action '回车确认'
Assert-Equal 1 $result.Index '下移一格'
Assert-Equal 'b' $result.Chosen.Key '选中的是第二项'
Assert-True $result.Checked.ContainsKey('b') 'b 被勾上'
# 0 项:驱动器里一个键都没有 —— 若它读键就会抛错,所以"不抛错且返回 cancel"同时证明它没等按键
$emptyList = @()
$empty = Invoke-BakNRetMenu -Title '测试' -Items $emptyList -Driver (New-BakNRetInputDriver -Script @())
Assert-Equal 'cancel' $empty.Action '空菜单直接返回 cancel'
Assert-Equal 'empty' $empty.Reason '原因标为 empty'
}
# ============================================================================
Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue