From d9d7967e64c1bb46eb4c042565f0034547ab3e4e Mon Sep 17 00:00:00 2001 From: Shuery <2463253700@qq.com> Date: Sun, 27 Sep 2026 15:52:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(tui):=20=E5=AE=9A=E4=BD=8D=E5=86=99?= =?UTF-8?q?=E4=B8=8E=E6=8C=89=E5=88=97=E5=AF=B9=E9=BD=90=EF=BC=88=E7=AC=AC?= =?UTF-8?q?=E4=B8=80=E8=BD=AE=E7=AC=AC=E4=B8=89=E5=9D=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Format-BakNRetPaddedText:按**列宽**补齐或裁剪,且绝不切开宽字符 —— 剩余宽度差 1 列而下一个字符要占 2 列时,停下用空格补那 1 列。返回的字符串保证恰好 N 列,这条不变式是画边框的基础(按 .Length 补会让边框歪,按 .Length 裁会把汉字劈成半个显示成乱码)。 Write-BakNRetAt:第一道闸门是 [Console]::IsOutputRedirected,**不是** $Host.UI.SupportsVirtualTerminal(实测后者无控制台时仍返回 True,而 SetCursorPosition 会抛异常)。没有控制台就完全不定位、只把文本写出去;坐标越界时跳过定位但仍写出文本 —— 计划任务与窄窗口不该让 TUI 崩掉,只是画得难看一点。颜色走 Write-Host -ForegroundColor(零 VT 依赖)。 验收:断言 10 条(含"裁剪后恰好 5 列且不是 4 个字符"这条关键判据);两版手工输出一致;test.ps1 9/9 全绿。 --- BakNRet/BakNRet.psd1 | 2 + BakNRet/BakNRet.psm1 | 5 +++ BakNRet/Public/Format-BakNRetPaddedText.ps1 | 49 +++++++++++++++++++++ BakNRet/Public/Write-BakNRetAt.ps1 | 45 +++++++++++++++++++ tests/Run-Tests.ps1 | 25 +++++++++++ 5 files changed, 126 insertions(+) create mode 100644 BakNRet/Public/Format-BakNRetPaddedText.ps1 create mode 100644 BakNRet/Public/Write-BakNRetAt.ps1 diff --git a/BakNRet/BakNRet.psd1 b/BakNRet/BakNRet.psd1 index b531964..179ec48 100644 --- a/BakNRet/BakNRet.psd1 +++ b/BakNRet/BakNRet.psd1 @@ -19,6 +19,8 @@ CompatiblePSEditions = @('Desktop', 'Core') FunctionsToExport = @( + 'Format-BakNRetPaddedText', + 'Write-BakNRetAt', 'Get-BakNRetKeyName', 'New-BakNRetInputDriver', 'Read-BakNRetKey', diff --git a/BakNRet/BakNRet.psm1 b/BakNRet/BakNRet.psm1 index 1b68153..cb9d0b1 100644 --- a/BakNRet/BakNRet.psm1 +++ b/BakNRet/BakNRet.psm1 @@ -150,7 +150,12 @@ . (Join-Path $PSScriptRoot 'Public\New-BakNRetInputDriver.ps1') . (Join-Path $PSScriptRoot 'Public\Read-BakNRetKey.ps1') +. (Join-Path $PSScriptRoot 'Public\Format-BakNRetPaddedText.ps1') +. (Join-Path $PSScriptRoot 'Public\Write-BakNRetAt.ps1') + Export-ModuleMember -Function @( + 'Format-BakNRetPaddedText', + 'Write-BakNRetAt', 'Get-BakNRetKeyName', 'New-BakNRetInputDriver', 'Read-BakNRetKey', diff --git a/BakNRet/Public/Format-BakNRetPaddedText.ps1 b/BakNRet/Public/Format-BakNRetPaddedText.ps1 new file mode 100644 index 0000000..6a9574b --- /dev/null +++ b/BakNRet/Public/Format-BakNRetPaddedText.ps1 @@ -0,0 +1,49 @@ +function Format-BakNRetPaddedText { + <# + .SYNOPSIS + 把文本按**列宽**补齐或裁剪到指定宽度。 + + .DESCRIPTION + 为什么不能用 `.PadRight()` / `.Substring()`:中文 1 个 char 占 2 列。按字符数补,边框会歪; + 按字符数裁,会把一个汉字劈成半个、显示成乱码。 + + 这里一律按 Get-BakNRetCellWidth 算,并且**绝不切开一个宽字符** —— 当剩余宽度差 1 列而下一个 + 字符要占 2 列时,就停下并用空格补上那 1 列。返回的字符串保证**恰好 Width 列**(Width ≤ 0 时 + 返回空串),这条不变式是画边框与对齐的基础。 + #> + param( + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Text, + [Parameter(Mandatory = $true)][int]$Width + ) + + if ($Width -le 0) { return '' } + + $textWidth = Get-BakNRetCellWidth -Text $Text + if ($textWidth -le $Width) { + return $Text + (' ' * ($Width - $textWidth)) + } + + # 超宽:按列裁剪,绝不切开代理对或宽字符 + $kept = '' + $used = 0 + $index = 0 + while ($index -lt $Text.Length) { + $chunk = [string]$Text[$index] + $high = [int]$Text[$index] + if ($high -ge 0xD800 -and $high -le 0xDBFF -and ($index + 1) -lt $Text.Length) { + $low = [int]$Text[$index + 1] + if ($low -ge 0xDC00 -and $low -le 0xDFFF) { + $chunk = $Text.Substring($index, 2) + $index++ + } + } + $index++ + + $chunkWidth = Get-BakNRetCellWidth -Text $chunk + if (($used + $chunkWidth) -gt $Width) { break } + $kept += $chunk + $used += $chunkWidth + } + + return $kept + (' ' * ($Width - $used)) +} diff --git a/BakNRet/Public/Write-BakNRetAt.ps1 b/BakNRet/Public/Write-BakNRetAt.ps1 new file mode 100644 index 0000000..20016d9 --- /dev/null +++ b/BakNRet/Public/Write-BakNRetAt.ps1 @@ -0,0 +1,45 @@ +function Write-BakNRetAt { + <# + .SYNOPSIS + 在指定坐标写一段文本;没有控制台时退化成普通输出。 + + .DESCRIPTION + 第一道闸门是 `[Console]::IsOutputRedirected`,**不是** `$Host.UI.SupportsVirtualTerminal` —— + 实测后者在没有控制台时仍返回 `True`(撒谎),而 `[Console]::SetCursorPosition` 会直接抛异常。 + 所以:没有控制台就完全不定位、只把文本打出来,让重定向 / 日志 / CI 场景拿到可读输出而不是异常。 + + 坐标会被夹到当前窗口内:越界时跳过定位但**仍然写出文本**。计划任务与窄窗口都不该让 TUI 崩掉, + 只是画得难看一点。 + + 颜色走 `Write-Host -ForegroundColor`(零 VT 依赖,conhost 与 Windows Terminal 都行), + 这样即使某个环境的 VT 没开,界面仍能有眼色而不是一屏转义序列。 + #> + param( + [Parameter(Mandatory = $true)][int]$X, + [Parameter(Mandatory = $true)][int]$Y, + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Text, + [string]$ForegroundColor, + [switch]$NoNewline + ) + + if ([Console]::IsOutputRedirected) { + # 没有控制台:不定位、不抛错,只把文本写出去 + if ($ForegroundColor) { Write-Host $Text -ForegroundColor $ForegroundColor -NoNewline:$NoNewline } + else { Write-Host $Text -NoNewline:$NoNewline } + return + } + + try { + $windowWidth = [Console]::WindowWidth + $windowHeight = [Console]::WindowHeight + if ($X -ge 0 -and $X -lt $windowWidth -and $Y -ge 0 -and $Y -lt $windowHeight) { + [Console]::SetCursorPosition($X, $Y) + } + } + catch { + # 定位失败不影响把文本写出去:画得难看,好过整个运行失败 + } + + if ($ForegroundColor) { Write-Host $Text -ForegroundColor $ForegroundColor -NoNewline:$NoNewline } + else { Write-Host $Text -NoNewline:$NoNewline } +} diff --git a/tests/Run-Tests.ps1 b/tests/Run-Tests.ps1 index 6ffe1bd..4205d4d 100644 --- a/tests/Run-Tests.ps1 +++ b/tests/Run-Tests.ps1 @@ -1465,6 +1465,31 @@ Test-Case '按键归一化与输入缝:映射可无控制台测,序列用尽 Assert-True $threw2 '没有任何输入来源时必须抛错' } +Test-Case 'TUI 文本对齐:按列宽补齐/裁剪,绝不切开宽字符,无控制台下不抛错' { + # 补齐:按列宽而不是字符数 + $padded = Format-BakNRetPaddedText -Text '中文' -Width 10 + Assert-Equal 10 (Get-BakNRetCellWidth -Text $padded) '补齐后应当恰好 10 列' + Assert-Equal 8 $padded.Length '10 列 = 8 个字符(两个汉字各占 2 列)' + Assert-Equal 'AB ' (Format-BakNRetPaddedText -Text 'AB' -Width 5) 'ASCII 补齐' + + # 裁剪:'中文字' 是 6 列,裁到 5 列必须停在 '中文'(4 列)+ 1 个空格, + # 而不是把第三个汉字劈成半个 —— 那会显示成乱码。 + $clipped = Format-BakNRetPaddedText -Text '中文字' -Width 5 + Assert-Equal 5 (Get-BakNRetCellWidth -Text $clipped) '裁剪后应当恰好 5 列' + Assert-Equal 3 $clipped.Length '3 个字符(中文 + 1 空格),不是 4' + Assert-Equal '中' $clipped.Substring(0, 1) '前两个汉字保留' + Assert-Equal ' ' $clipped.Substring(2, 1) '剩下的 1 列用空格补' + + # 边界:宽度 0 / 负数返回空串;空文本也能补齐 + Assert-Equal 0 (Format-BakNRetPaddedText -Text 'abc' -Width 0).Length '宽度 0 返回空串' + Assert-Equal 4 (Format-BakNRetPaddedText -Text '' -Width 4).Length '空文本补成纯空格' + + # 无控制台(本进程的真实环境就是重定向)+ 越界坐标:不能抛错 + $threw = $false + try { Write-BakNRetAt -X 9999 -Y 9999 -Text 'x' } catch { $threw = $true } + Assert-True (-not $threw) '没有控制台且坐标越界时也不能抛错(TUI 画得难看,好过运行失败)' +} + # ============================================================================ Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue