feat(tui): 菜单状态机(第一轮第四块·上)

把菜单拆成"纯状态机 + 薄渲染":状态机进断言,渲染只需人肉看长相。规则:Up/k 上移、Down/j 下移、到头绕回;Space 只切换当前项(单选模式无效);Enter=confirm、Esc=cancel;返回新对象而不就地改(状态属于调用方,ADR-0011)。

特别钉住空菜单:第一次使用时归档目录就是空的,菜单打开就是空菜单 —— 方向键与空格都不该抛错,只有 Esc 能退出。这类"初始状态为空"的路径最容易在真终端里才被发现,而那时已经晚了。

验收:断言 16 条;两版手工输出一致;test.ps1 9/9 全绿。
This commit is contained in:
Shuery committed 2026-09-27 15:55:31 +08:00
1 parent d9d7967e64
commit 347959d677
4 files changed
+101

No files matched your search

+1
View File
@@ -19,6 +19,7 @@
CompatiblePSEditions = @('Desktop', 'Core') CompatiblePSEditions = @('Desktop', 'Core')
FunctionsToExport = @( FunctionsToExport = @(
'Update-BakNRetMenuState',
'Format-BakNRetPaddedText', 'Format-BakNRetPaddedText',
'Write-BakNRetAt', 'Write-BakNRetAt',
'Get-BakNRetKeyName', 'Get-BakNRetKeyName',
+3
View File
@@ -153,7 +153,10 @@
. (Join-Path $PSScriptRoot 'Public\Format-BakNRetPaddedText.ps1') . (Join-Path $PSScriptRoot 'Public\Format-BakNRetPaddedText.ps1')
. (Join-Path $PSScriptRoot 'Public\Write-BakNRetAt.ps1') . (Join-Path $PSScriptRoot 'Public\Write-BakNRetAt.ps1')
. (Join-Path $PSScriptRoot 'Public\Update-BakNRetMenuState.ps1')
Export-ModuleMember -Function @( Export-ModuleMember -Function @(
'Update-BakNRetMenuState',
'Format-BakNRetPaddedText', 'Format-BakNRetPaddedText',
'Write-BakNRetAt', 'Write-BakNRetAt',
'Get-BakNRetKeyName', 'Get-BakNRetKeyName',
@@ -0,0 +1,67 @@
function Update-BakNRetMenuState {
<#
.SYNOPSIS
菜单状态机的纯函数部分:给一个状态和一个键名,返回新状态。
.DESCRIPTION
为什么把状态机单独拆出来:菜单的"难看"与"难用"是两件事。渲染必须有真终端才能测,而
"上下键到头会不会绕回去、空格切换的是哪一项、Esc 是返回还是退出"这些规则是纯逻辑——
拆出来之后它们能进断言,整块菜单则只需要人肉看一遍长相。
规则(都钉在断言里):
* Up / Char:k 上移,Down / Char:j 下移,**到头绕回**(菜单短的时候比夹住更好用);
* Space 勾选/取消当前项(只在多选模式下),其它项不受影响;
* Enter → Action = 'confirm',Esc → Action = 'cancel';
* **空列表不抛错**:第一次使用时归档目录是空的,菜单打开就是空菜单,这不该崩。
空列表下方向键与空格都不改变状态。
返回的是**新对象**而不是就地改动:状态属于调用方(ADR-0011),调用方换引用即可。
#>
param(
[Parameter(Mandatory = $true)]$Items,
[int]$Index = 0,
$Checked = $null,
[bool]$MultiSelect = $true,
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Key
)
$itemList = @($Items)
$count = $itemList.Count
$checkedKeys = @{}
if ($Checked) { foreach ($k in @($Checked.Keys)) { $checkedKeys[$k] = $true } }
$newIndex = $Index
$action = 'none'
if ($count -gt 0) {
switch ($Key) {
'Up' { $newIndex = ($Index - 1 + $count) % $count }
'Char:k' { $newIndex = ($Index - 1 + $count) % $count }
'Down' { $newIndex = ($Index + 1) % $count }
'Char:j' { $newIndex = ($Index + 1) % $count }
'Space' {
if ($MultiSelect) {
$item = $itemList[$Index]
$itemKey = [string]$item.Key
if ($checkedKeys.ContainsKey($itemKey)) { $checkedKeys.Remove($itemKey) }
else { $checkedKeys[$itemKey] = $true }
}
}
'Enter' { $action = 'confirm' }
'Esc' { $action = 'cancel' }
}
}
else {
# 空菜单:只认退出,别的键都不改变任何东西
if ($Key -eq 'Esc') { $action = 'cancel' }
}
if ($newIndex -lt 0) { $newIndex = 0 }
if ($count -gt 0 -and $newIndex -ge $count) { $newIndex = $count - 1 }
return [pscustomobject]@{
Index = $newIndex
Checked = $checkedKeys
Action = $action
}
}
+30
View File
@@ -1490,6 +1490,36 @@ Test-Case 'TUI 文本对齐:按列宽补齐/裁剪,绝不切开宽字符,
Assert-True (-not $threw) '没有控制台且坐标越界时也不能抛错(TUI 画得难看,好过运行失败)' Assert-True (-not $threw) '没有控制台且坐标越界时也不能抛错(TUI 画得难看,好过运行失败)'
} }
Test-Case '菜单状态机:绕回、空格切换、确认/取消、空菜单不崩' {
$items = @([pscustomobject]@{ Key = 'a' }, [pscustomobject]@{ Key = 'b' }, [pscustomobject]@{ Key = 'c' })
# 到头绕回(菜单短的时候比夹住好用)
Assert-Equal 2 (Update-BakNRetMenuState -Items $items -Index 0 -Key 'Up').Index '从第一个往上绕到最后一个'
Assert-Equal 0 (Update-BakNRetMenuState -Items $items -Index 2 -Key 'Down').Index '从最后一个往下绕回第一个'
Assert-Equal 1 (Update-BakNRetMenuState -Items $items -Index 0 -Key 'Down').Index '往下走一格'
Assert-Equal 2 (Update-BakNRetMenuState -Items $items -Index 0 -Key 'Char:k').Index 'vim 的 k = 上'
Assert-Equal 1 (Update-BakNRetMenuState -Items $items -Index 0 -Key 'Char:j').Index 'vim 的 j = 下'
# 空格只切换当前项,再按一次取消
$s1 = Update-BakNRetMenuState -Items $items -Index 1 -Key 'Space'
Assert-Equal 1 @($s1.Checked.Keys).Count '勾选一项'
Assert-True $s1.Checked.ContainsKey('b') '勾的是当前项'
$s2 = Update-BakNRetMenuState -Items $items -Index 1 -Key 'Space' -Checked $s1.Checked
Assert-Equal 0 @($s2.Checked.Keys).Count '再按一次取消勾选'
Assert-Equal 0 @(Update-BakNRetMenuState -Items $items -Index 0 -Key 'Space' -MultiSelect $false).Checked.Keys.Count '单选模式下空格不勾选'
# 确认 / 取消 / 其它键
Assert-Equal 'confirm' (Update-BakNRetMenuState -Items $items -Key 'Enter').Action '回车 = 确认'
Assert-Equal 'cancel' (Update-BakNRetMenuState -Items $items -Key 'Esc').Action 'Esc = 取消'
Assert-Equal 'none' (Update-BakNRetMenuState -Items $items -Key 'Char:z').Action '别的键什么都不做'
# 空菜单:第一次使用时归档目录就是空的,打开菜单不该崩
$empty = @()
Assert-Equal 0 (Update-BakNRetMenuState -Items $empty -Key 'Down').Index '空菜单按方向键不抛错'
Assert-Equal 0 @(Update-BakNRetMenuState -Items $empty -Key 'Space').Checked.Keys.Count '空菜单按空格不抛错'
Assert-Equal 'cancel' (Update-BakNRetMenuState -Items $empty -Key 'Esc').Action '空菜单仍然能退出'
}
# ============================================================================ # ============================================================================
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