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:
1 parent
6315c72f69
commit
835fb0cf2b
4 files changed
+129
No files matched your search
@@ -19,6 +19,7 @@
|
|||||||
CompatiblePSEditions = @('Desktop', 'Core')
|
CompatiblePSEditions = @('Desktop', 'Core')
|
||||||
|
|
||||||
FunctionsToExport = @(
|
FunctionsToExport = @(
|
||||||
|
'Invoke-BakNRetMenu',
|
||||||
'Update-BakNRetMenuState',
|
'Update-BakNRetMenuState',
|
||||||
'Format-BakNRetPaddedText',
|
'Format-BakNRetPaddedText',
|
||||||
'Write-BakNRetAt',
|
'Write-BakNRetAt',
|
||||||
|
|||||||
@@ -155,7 +155,10 @@
|
|||||||
|
|
||||||
. (Join-Path $PSScriptRoot 'Public\Update-BakNRetMenuState.ps1')
|
. (Join-Path $PSScriptRoot 'Public\Update-BakNRetMenuState.ps1')
|
||||||
|
|
||||||
|
. (Join-Path $PSScriptRoot 'Public\Invoke-BakNRetMenu.ps1')
|
||||||
|
|
||||||
Export-ModuleMember -Function @(
|
Export-ModuleMember -Function @(
|
||||||
|
'Invoke-BakNRetMenu',
|
||||||
'Update-BakNRetMenuState',
|
'Update-BakNRetMenuState',
|
||||||
'Format-BakNRetPaddedText',
|
'Format-BakNRetPaddedText',
|
||||||
'Write-BakNRetAt',
|
'Write-BakNRetAt',
|
||||||
|
|||||||
@@ -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 = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 $sandbox -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
|||||||
Reference in new issue
Block a user