From 835fb0cf2bf3387e0ae021b9043ee309a06c58ac Mon Sep 17 00:00:00 2001 From: Shuery <2463253700@qq.com> Date: Sun, 27 Sep 2026 16:13:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(tui):=20=E8=8F=9C=E5=8D=95=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E4=B8=8E=E5=BE=AA=E7=8E=AF=EF=BC=88=E7=AC=AC=E4=B8=80?= =?UTF-8?q?=E8=BD=AE=E7=AC=AC=E5=9B=9B=E5=9D=97=C2=B7=E4=B8=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 形态是行内(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。 --- BakNRet/BakNRet.psd1 | 1 + BakNRet/BakNRet.psm1 | 3 + BakNRet/Public/Invoke-BakNRetMenu.ps1 | 92 +++++++++++++++++++++++++++ tests/Run-Tests.ps1 | 33 ++++++++++ 4 files changed, 129 insertions(+) create mode 100644 BakNRet/Public/Invoke-BakNRetMenu.ps1 diff --git a/BakNRet/BakNRet.psd1 b/BakNRet/BakNRet.psd1 index 2f5b98b..51799a8 100644 --- a/BakNRet/BakNRet.psd1 +++ b/BakNRet/BakNRet.psd1 @@ -19,6 +19,7 @@ CompatiblePSEditions = @('Desktop', 'Core') FunctionsToExport = @( + 'Invoke-BakNRetMenu', 'Update-BakNRetMenuState', 'Format-BakNRetPaddedText', 'Write-BakNRetAt', diff --git a/BakNRet/BakNRet.psm1 b/BakNRet/BakNRet.psm1 index 8dd7f0d..86846aa 100644 --- a/BakNRet/BakNRet.psm1 +++ b/BakNRet/BakNRet.psm1 @@ -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', diff --git a/BakNRet/Public/Invoke-BakNRetMenu.ps1 b/BakNRet/Public/Invoke-BakNRetMenu.ps1 new file mode 100644 index 0000000..30562a0 --- /dev/null +++ b/BakNRet/Public/Invoke-BakNRetMenu.ps1 @@ -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 = '' + } +} diff --git a/tests/Run-Tests.ps1 b/tests/Run-Tests.ps1 index 50b09f8..a52ef03 100644 --- a/tests/Run-Tests.ps1 +++ b/tests/Run-Tests.ps1 @@ -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