feat(tui): 菜单状态机(第一轮第四块·上)
把菜单拆成"纯状态机 + 薄渲染":状态机进断言,渲染只需人肉看长相。规则:Up/k 上移、Down/j 下移、到头绕回;Space 只切换当前项(单选模式无效);Enter=confirm、Esc=cancel;返回新对象而不就地改(状态属于调用方,ADR-0011)。 特别钉住空菜单:第一次使用时归档目录就是空的,菜单打开就是空菜单 —— 方向键与空格都不该抛错,只有 Esc 能退出。这类"初始状态为空"的路径最容易在真终端里才被发现,而那时已经晚了。 验收:断言 16 条;两版手工输出一致;test.ps1 9/9 全绿。
This commit is contained in:
1 parent
d9d7967e64
commit
347959d677
4 files changed
+101
No files matched your search
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user