diff --git a/BakNRet/BakNRet.psd1 b/BakNRet/BakNRet.psd1 index 179ec48..2f5b98b 100644 --- a/BakNRet/BakNRet.psd1 +++ b/BakNRet/BakNRet.psd1 @@ -19,6 +19,7 @@ CompatiblePSEditions = @('Desktop', 'Core') FunctionsToExport = @( + 'Update-BakNRetMenuState', 'Format-BakNRetPaddedText', 'Write-BakNRetAt', 'Get-BakNRetKeyName', diff --git a/BakNRet/BakNRet.psm1 b/BakNRet/BakNRet.psm1 index cb9d0b1..8dd7f0d 100644 --- a/BakNRet/BakNRet.psm1 +++ b/BakNRet/BakNRet.psm1 @@ -153,7 +153,10 @@ . (Join-Path $PSScriptRoot 'Public\Format-BakNRetPaddedText.ps1') . (Join-Path $PSScriptRoot 'Public\Write-BakNRetAt.ps1') +. (Join-Path $PSScriptRoot 'Public\Update-BakNRetMenuState.ps1') + Export-ModuleMember -Function @( + 'Update-BakNRetMenuState', 'Format-BakNRetPaddedText', 'Write-BakNRetAt', 'Get-BakNRetKeyName', diff --git a/BakNRet/Public/Update-BakNRetMenuState.ps1 b/BakNRet/Public/Update-BakNRetMenuState.ps1 new file mode 100644 index 0000000..a2e65fc --- /dev/null +++ b/BakNRet/Public/Update-BakNRetMenuState.ps1 @@ -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 + } +} diff --git a/tests/Run-Tests.ps1 b/tests/Run-Tests.ps1 index 4205d4d..0459fc3 100644 --- a/tests/Run-Tests.ps1 +++ b/tests/Run-Tests.ps1 @@ -1490,6 +1490,36 @@ Test-Case '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 $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue