feat(tui): 定位写与按列对齐(第一轮第三块)

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 全绿。
This commit is contained in:
Shuery committed 2026-09-27 15:52:34 +08:00
1 parent f2959a4279
commit d9d7967e64
5 files changed
+126

No files matched your search

+2
View File
@@ -19,6 +19,8 @@
CompatiblePSEditions = @('Desktop', 'Core') CompatiblePSEditions = @('Desktop', 'Core')
FunctionsToExport = @( FunctionsToExport = @(
'Format-BakNRetPaddedText',
'Write-BakNRetAt',
'Get-BakNRetKeyName', 'Get-BakNRetKeyName',
'New-BakNRetInputDriver', 'New-BakNRetInputDriver',
'Read-BakNRetKey', 'Read-BakNRetKey',
+5
View File
@@ -150,7 +150,12 @@
. (Join-Path $PSScriptRoot 'Public\New-BakNRetInputDriver.ps1') . (Join-Path $PSScriptRoot 'Public\New-BakNRetInputDriver.ps1')
. (Join-Path $PSScriptRoot 'Public\Read-BakNRetKey.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 @( Export-ModuleMember -Function @(
'Format-BakNRetPaddedText',
'Write-BakNRetAt',
'Get-BakNRetKeyName', 'Get-BakNRetKeyName',
'New-BakNRetInputDriver', 'New-BakNRetInputDriver',
'Read-BakNRetKey', 'Read-BakNRetKey',
@@ -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))
}
+45
View File
@@ -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 }
}
+25
View File
@@ -1465,6 +1465,31 @@ Test-Case '按键归一化与输入缝:映射可无控制台测,序列用尽
Assert-True $threw2 '没有任何输入来源时必须抛错' 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 $sandbox -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue