diff --git a/CHANGELOG.md b/CHANGELOG.md index 055d1c4..8ca7e60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Interactive tool pickers use Quiet Accent semantic colors when a compatible console is available, + while preserving ASCII/plain output for redirected sessions and `NO_COLOR`. (#507) + ### Fixed ### Removed diff --git a/README.md b/README.md index dc781c5..8a7b477 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,10 @@ Picker behavior is consistent across platforms: - Confirming installs the checked tools through the same ordered selection engine used by flags. - Cancelling exits successfully without installing anything and prints `Install cancelled.` - Confirming with every item unchecked exits successfully and prints `Nothing selected, exiting.` +- In supported interactive consoles, Quiet Accent uses cyan for headings and focus, green for + checked markers and confirmations, and yellow for opt-ins, cancellations, and warnings. + The ASCII markers and labels remain the source of meaning. Styling is suppressed for redirected + output, `NO_COLOR`, and unsupported terminals (including Bash with `TERM=dumb`). Controls depend on the shell: diff --git a/scripts/linux/lib/tui.sh b/scripts/linux/lib/tui.sh index 86cbcfd..987be3a 100644 --- a/scripts/linux/lib/tui.sh +++ b/scripts/linux/lib/tui.sh @@ -7,9 +7,40 @@ # show_tool_menu # Sets globals: _MENU_SELECTION (CSV), _MENU_CANCELLED (0|1) # -# Test seam: set _TUI_ARROW_NAV_OVERRIDE=0|1 to force a specific nav path. +# Test seams: set _TUI_ARROW_NAV_OVERRIDE=0|1 to force a specific nav path, +# and _TUI_COLOR_OVERRIDE=0|1 to force plain or colored rendering. # ponytail: eval for bash 3.2 nameref compat; upgrade to local -n when 4.3 is minimum. +# --------------------------------------------------------------------------- +# _tui_color_enabled -- true when menu styling is safe, or test-forced. +# NO_COLOR always wins, including over the force-color test seam. +# --------------------------------------------------------------------------- +_tui_color_enabled() { + [[ "${_TUI_COLOR_OVERRIDE:-}" == "0" ]] && return 1 + [[ -n "${NO_COLOR:-}" ]] && return 1 + [[ "${_TUI_COLOR_OVERRIDE:-}" == "1" ]] && return 0 + [[ -t 1 ]] || return 1 + [[ "${TERM:-}" != "dumb" ]] || return 1 + return 0 +} + +# Fixed native SGR styles. Each function emits plain ASCII text when disabled. +_tui_style_heading() { + if _tui_color_enabled; then printf '\033[1;36m%s\033[0m' "$1"; else printf '%s' "$1"; fi +} +_tui_style_checked() { + if _tui_color_enabled; then printf '\033[32m%s\033[0m' "$1"; else printf '%s' "$1"; fi +} +_tui_style_optin() { + if _tui_color_enabled; then printf '\033[33m%s\033[0m' "$1"; else printf '%s' "$1"; fi +} +_tui_style_confirmation() { + if _tui_color_enabled; then printf '\033[32m%s\033[0m' "$1"; else printf '%s' "$1"; fi +} +_tui_style_warning() { + if _tui_color_enabled; then printf '\033[33m%s\033[0m' "$1"; else printf '%s' "$1"; fi +} + # --------------------------------------------------------------------------- # _tui_render_list -- draw the full menu list to stdout # $1: cursor index (-1 = no cursor indicator) @@ -36,7 +67,12 @@ _tui_render_list() { if [[ "$checked" == "1" ]]; then check_str="[x]"; else check_str="[ ]"; fi if [[ "$is_def" == "1" ]]; then label="(default)"; else label="(opt-in)"; fi if [[ "$cursor" -eq "$i" ]]; then pointer=">"; else pointer=" "; fi - printf '%s %s %s %s\n' "$pointer" "$check_str" "$tool" "$label" + if [[ "$pointer" == ">" ]]; then _tui_style_heading "$pointer"; else printf '%s' "$pointer"; fi + printf ' ' + if [[ "$checked" == "1" ]]; then _tui_style_checked "$check_str"; else printf '%s' "$check_str"; fi + printf ' %s ' "$tool" + if [[ "$is_def" == "1" ]]; then printf '%s' "$label"; else _tui_style_optin "$label"; fi + printf '\n' i=$((i+1)) done <<< "$tools_nl" } @@ -188,7 +224,8 @@ _tui_arrow_mode() { IFS=' ' read -ra checked_arr <<< "$checked_sp" printf '\n' - printf 'Arrow keys to move, Space to toggle, Enter to confirm, q/ESC to cancel.\n\n' + _tui_style_heading 'Arrow keys to move, Space to toggle, Enter to confirm, q/ESC to cancel.' + printf '\n\n' _tui_render_list "$cursor" "$tools_nl" "${checked_arr[*]}" "$is_def_sp" local done_=0 k1 k2 k3 key @@ -212,7 +249,9 @@ _tui_arrow_mode() { if [[ "$_kd_cancelled" == "1" ]]; then _MENU_CANCELLED=1 - printf '\nInstall cancelled.\n' + printf '\n' + _tui_style_warning 'Install cancelled.' + printf '\n' return 0 fi [[ "$_kd_done" == "1" ]] && done_=1 @@ -229,6 +268,8 @@ _tui_arrow_mode() { i=$((i+1)) done <<< "$tools_nl" + _tui_style_confirmation 'Selection confirmed.' + printf '\n' _MENU_SELECTION="$sel" } @@ -254,7 +295,8 @@ _tui_numbered_mode() { ;; q|Q) _MENU_CANCELLED=1 - printf 'Install cancelled.\n' + _tui_style_warning 'Install cancelled.' + printf '\n' return 0 ;; a|A) @@ -281,6 +323,8 @@ _tui_numbered_mode() { for i in $(seq 0 $((count-1))); do [[ "${checked_arr[$i]}" == "1" ]] && sel="${sel:+${sel},}${tool_arr[$i]}" done + _tui_style_confirmation 'Selection confirmed.' + printf '\n' _MENU_SELECTION="$sel" } @@ -290,14 +334,20 @@ _tui_numbered_mode() { # --------------------------------------------------------------------------- _tui_numbered_render() { local _tname="$1" _cname="$2" _dname="$3" _cnt="$4" - printf '\nArrow keys unavailable (bash 3.2). Number to toggle, a=all, Enter=confirm, q=cancel.\n' + printf '\n' + _tui_style_heading 'Arrow keys unavailable (bash 3.2). Number to toggle, a=all, Enter=confirm, q=cancel.' + printf '\n' local i check_str label _t _c _d for i in $(seq 0 $((_cnt-1))); do # shellcheck disable=SC2086 eval "_t=\"\${${_tname}[$i]}\"; _c=\"\${${_cname}[$i]}\"; _d=\"\${${_dname}[$i]}\"" if [[ "$_c" == "1" ]]; then check_str="[x]"; else check_str="[ ]"; fi if [[ "$_d" == "1" ]]; then label="(default)"; else label="(opt-in)"; fi - printf '%d. %s %s %s\n' "$((i+1))" "$check_str" "$_t" "$label" + printf '%d. ' "$((i+1))" + if [[ "$_c" == "1" ]]; then _tui_style_checked "$check_str"; else printf '%s' "$check_str"; fi + printf ' %s ' "$_t" + if [[ "$_d" == "1" ]]; then printf '%s' "$label"; else _tui_style_optin "$label"; fi + printf '\n' done printf '\n> ' } diff --git a/scripts/windows/lib/tui.ps1 b/scripts/windows/lib/tui.ps1 index 3e1c577..168dd5c 100644 --- a/scripts/windows/lib/tui.ps1 +++ b/scripts/windows/lib/tui.ps1 @@ -6,6 +6,99 @@ # PS 5.1 ASCII-only: no smart quotes, em-dashes, arrows, or non-ASCII characters. # No Write-Host. Console output via [Console]::Write / [Console]::WriteLine only. +# --------------------------------------------------------------------------- +# Quiet Accent console styling. This remains deliberately local to the TUI: +# redirected/plain output is unchanged, and colors always restore immediately. +# Test seam: _PS_TUI_COLOR_OVERRIDE=0|1 forces plain or colored rendering. +# --------------------------------------------------------------------------- +$script:_TuiWriteOverride = $null + +function Test-TuiColorEnabled { + if ($env:_PS_TUI_COLOR_OVERRIDE -eq '0') { return $false } + if (-not [string]::IsNullOrEmpty($env:NO_COLOR)) { return $false } + + try { + if ($null -eq $Host.UI.RawUI) { return $false } + $null = [Console]::ForegroundColor + } + catch { + return $false + } + + if ($env:_PS_TUI_COLOR_OVERRIDE -eq '1') { return $true } + return -not [Console]::IsOutputRedirected +} + +function Get-TuiRoleColor { + param( + [ValidateSet('Heading', 'Focus', 'Checked', 'OptIn', 'Confirmation', 'Warning', 'Cancel')] + [string]$Role + ) + + switch ($Role) { + 'Heading' { return [ConsoleColor]::Cyan } + 'Focus' { return [ConsoleColor]::Cyan } + 'Checked' { return [ConsoleColor]::Green } + 'OptIn' { return [ConsoleColor]::Yellow } + 'Confirmation' { return [ConsoleColor]::Green } + 'Warning' { return [ConsoleColor]::Yellow } + 'Cancel' { return [ConsoleColor]::Yellow } + } +} + +function Write-TuiRaw { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingWriteHost', '', + Justification = 'The interactive TUI must write directly to the console without Write-Host.')] + param( + [string]$Text, + [bool]$ErrorOutput = $false + ) + + if ($null -ne $script:_TuiWriteOverride) { + & $script:_TuiWriteOverride $Text $ErrorOutput + return + } + if ($ErrorOutput) { + [Console]::Error.Write($Text) + } else { + [Console]::Write($Text) + } +} + +function Write-TuiStyled { + param( + [string]$Text, + [ConsoleColor]$Color, + [bool]$ErrorOutput = $false + ) + + if (-not (Test-TuiColorEnabled)) { + Write-TuiRaw -Text $Text -ErrorOutput $ErrorOutput + return + } + + $previousColor = [Console]::ForegroundColor + try { + [Console]::ForegroundColor = $Color + Write-TuiRaw -Text $Text -ErrorOutput $ErrorOutput + } + finally { + [Console]::ForegroundColor = $previousColor + } +} + +function Write-TuiLine { + param( + [string]$Text, + [ConsoleColor]$Color, + [bool]$ErrorOutput = $false + ) + + Write-TuiStyled -Text $Text -Color $Color -ErrorOutput $ErrorOutput + Write-TuiRaw -Text ([Environment]::NewLine) -ErrorOutput $ErrorOutput +} + # --------------------------------------------------------------------------- # Resolve-FinalToolset: canonical ordered tool resolver. # Pure function -- no exit, no console output, no validation. @@ -81,13 +174,32 @@ function Show-ToolMenu { # Render-Menu as scriptblock (closure over $items, $checked, $cursor, $nDef) $renderMenu = { - [Console]::WriteLine('Select tools to install (defaults pre-checked):') + Write-TuiLine -Text 'Select tools to install (defaults pre-checked):' ` + -Color (Get-TuiRoleColor -Role 'Heading') [Console]::WriteLine('') for ($i = 0; $i -lt $items.Count; $i++) { $mark = if ($checked[$i]) { '[x]' } else { '[ ]' } $label = if ($i -lt $nDef) { '(default)' } else { '(opt-in)' } $arrow = if ($i -eq $cursor) { '>' } else { ' ' } - [Console]::WriteLine(" $arrow $mark $($items[$i]) $label") + Write-TuiRaw -Text ' ' + if ($arrow -eq '>') { + Write-TuiStyled -Text $arrow -Color (Get-TuiRoleColor -Role 'Focus') + } else { + Write-TuiRaw -Text $arrow + } + Write-TuiRaw -Text ' ' + if ($checked[$i]) { + Write-TuiStyled -Text $mark -Color (Get-TuiRoleColor -Role 'Checked') + } else { + Write-TuiRaw -Text $mark + } + Write-TuiRaw -Text " $($items[$i]) " + if ($i -lt $nDef) { + [Console]::WriteLine($label) + } else { + Write-TuiStyled -Text $label -Color (Get-TuiRoleColor -Role 'OptIn') + [Console]::WriteLine() + } } [Console]::WriteLine('') [Console]::WriteLine(' Up/Down=move Space=toggle A=all Enter=confirm Esc/Q=cancel') @@ -118,8 +230,9 @@ function Show-ToolMenu { } } catch { # ReadKey failure in a nominally interactive host: one clear warning, install cancelled - [Console]::Error.WriteLine( - "WARNING: Interactive menu failed ($($_.Exception.Message)). Installation is being cancelled.") + Write-TuiLine -Text ( + "WARNING: Interactive menu failed ($($_.Exception.Message)). Installation is being cancelled.") ` + -Color (Get-TuiRoleColor -Role 'Warning') -ErrorOutput $true return $null } @@ -129,5 +242,6 @@ function Show-ToolMenu { for ($i = 0; $i -lt $items.Count; $i++) { if ($checked[$i]) { $selected += $items[$i] } } + Write-TuiLine -Text 'Selection confirmed.' -Color (Get-TuiRoleColor -Role 'Confirmation') return ,$selected } diff --git a/scripts/windows/setup.ps1 b/scripts/windows/setup.ps1 index 8bb0827..e8c2ccc 100644 --- a/scripts/windows/setup.ps1 +++ b/scripts/windows/setup.ps1 @@ -243,7 +243,7 @@ if (Test-ShouldShowMenu ` } if ($null -eq $selectedNames) { - Write-Output 'Install cancelled.' + Write-TuiLine -Text 'Install cancelled.' -Color (Get-TuiRoleColor -Role 'Cancel') exit 0 } if ($selectedNames.Count -eq 0) { diff --git a/tests/test_tui_bash.sh b/tests/test_tui_bash.sh index a612bff..17db8e0 100644 --- a/tests/test_tui_bash.sh +++ b/tests/test_tui_bash.sh @@ -10,7 +10,8 @@ # # Automated (no TTY): key dispatch logic via _tui_arrow_handle_key (cursor # movement, toggle-all, Space, Enter, cancel, noop), _tui_checked_toggle_all -# state transitions, _tui_render_list output, and _tui_arrow_redraw ANSI bytes. +# state transitions, color fallback/roles, _tui_render_list output, and +# _tui_arrow_redraw ANSI bytes. # NOT covered (requires live interactive TTY, documented in PR): # Terminal byte delivery from keyboard to read loop, ANSI in-place rendering # fidelity in a real terminal (mintty, tmux, Terminal.app), numbered-toggle @@ -78,6 +79,95 @@ else fail "T_tui_sh_sources_clean: tui.sh source failed" fi +# --------------------------------------------------------------------------- +# T_color_roles_forced: deterministic color seam covers every Quiet Accent role +# --------------------------------------------------------------------------- +echo "" +echo "--- T_color_roles_forced ---" +color_hex="$( + _TUI_COLOR_OVERRIDE=1 NO_COLOR='' TERM=xterm bash -c " + source '${TUI_SH}' + tools=\$'alpha\nbeta' + _tui_style_heading 'Heading'; printf '\n' + _tui_render_list 0 \"\$tools\" '1 0' '1 0' + _tui_style_confirmation 'Selection confirmed.'; printf '\n' + _tui_style_warning 'Install cancelled.'; printf '\n' + " 2>&1 | od -An -tx1 | tr -d ' \n' +)" +if [[ "$color_hex" == *"1b5b313b33366d"* ]] && \ + [[ "$color_hex" == *"1b5b33326d"* ]] && \ + [[ "$color_hex" == *"1b5b33336d"* ]] && \ + [[ "$color_hex" == *"1b5b306d"* ]]; then + pass "T_color_roles_forced: cyan, green, yellow, and reset SGR roles render" +else + fail "T_color_roles_forced: expected Quiet Accent SGR roles (hex: ${color_hex})" +fi + +# --------------------------------------------------------------------------- +# T_color_plain_fallbacks: forced plain, NO_COLOR, TERM=dumb, and redirected +# output keep the existing ASCII rendering without SGR bytes. +# --------------------------------------------------------------------------- +echo "" +echo "--- T_color_plain_fallbacks ---" +plain_out="$( + _TUI_COLOR_OVERRIDE=0 bash -c " + source '${TUI_SH}' + tools=\$'alpha\nbeta' + _tui_render_list 0 \"\$tools\" '1 0' '1 0' + " 2>&1 +)" +if assert_contains "$plain_out" "> [x] alpha (default)" && \ + assert_contains "$plain_out" " [ ] beta (opt-in)" && \ + assert_not_contains "$plain_out" $'\033'; then + pass "T_color_plain_fallbacks[forced]: forced plain preserves ASCII list output" +else + fail "T_color_plain_fallbacks[forced]: forced plain output included style bytes" +fi + +no_color_out="$( + _TUI_COLOR_OVERRIDE=1 NO_COLOR=1 bash -c " + source '${TUI_SH}' + tools=\$'alpha\nbeta' + _tui_render_list 0 \"\$tools\" '1 0' '1 0' + " 2>&1 +)" +if assert_not_contains "$no_color_out" $'\033'; then + pass "T_color_plain_fallbacks[NO_COLOR]: NO_COLOR overrides forced color" +else + fail "T_color_plain_fallbacks[NO_COLOR]: NO_COLOR output included style bytes" +fi + +if TERM=dumb bash -c "source '${TUI_SH}'; _tui_color_enabled" >/dev/null 2>&1; then + fail "T_color_plain_fallbacks[TERM-dumb]: TERM=dumb unexpectedly enabled color" +else + pass "T_color_plain_fallbacks[TERM-dumb]: TERM=dumb disables color" +fi + +redirected_out="$( + TERM=xterm bash -c " + source '${TUI_SH}' + tools=\$'alpha\nbeta' + _tui_render_list 0 \"\$tools\" '1 0' '1 0' + " 2>&1 +)" +if assert_not_contains "$redirected_out" $'\033'; then + pass "T_color_plain_fallbacks[redirected]: non-TTY capture remains plain" +else + fail "T_color_plain_fallbacks[redirected]: redirected output included style bytes" +fi + +# --------------------------------------------------------------------------- +# T_tui_ascii_purity: Quiet Accent remains safe for ASCII-only terminals. +# --------------------------------------------------------------------------- +echo "" +echo "--- T_tui_ascii_purity ---" +non_ascii="$(LC_ALL=C tr -d '\000-\177' < "$TUI_SH")" +if [[ -z "$non_ascii" ]]; then + pass "T_tui_ascii_purity: tui.sh contains only ASCII bytes" +else + fail "T_tui_ascii_purity: tui.sh contains non-ASCII bytes" +fi + # --------------------------------------------------------------------------- # T_menu_resolve_defaults: all defaults selected -> run-log == defaults.txt # --------------------------------------------------------------------------- @@ -264,6 +354,23 @@ else echo " Output: $numbered_out" fi +numbered_hex="$( + _TUI_COLOR_OVERRIDE=1 bash -c " + source '${TUI_SH}' + _ta=(prereqs bravo) + _ca=(1 0) + _da=(1 0) + _tui_numbered_render _ta _ca _da 2 + " 2>&1 | od -An -tx1 | tr -d ' \n' +)" +if [[ "$numbered_hex" == *"1b5b313b33366d"* ]] && \ + [[ "$numbered_hex" == *"1b5b33326d"* ]] && \ + [[ "$numbered_hex" == *"1b5b33336d"* ]]; then + pass "T_numbered_render_runs[color]: Bash 3.2 fallback applies semantic colors without cursor bytes" +else + fail "T_numbered_render_runs[color]: numbered color roles missing (hex: ${numbered_hex})" +fi + # --------------------------------------------------------------------------- # T_render_list_runs: _tui_render_list draws list with cursor and labels # --------------------------------------------------------------------------- @@ -377,6 +484,20 @@ else pass "T_arrow_redraw_stable[ansi-no-creep]: ESC[4A absent -- no viewport creep" fi +_redraw_color_hex="$( + _TUI_COLOR_OVERRIDE=1 bash -c " + source '${TUI_SH}' + tools=\$'alpha\nbeta\ngamma' + _tui_arrow_redraw 3 0 \"\$tools\" '1 1 0' '1 0 0' + " 2>&1 | od -An -tx1 | tr -d ' \n' +)" +if [[ "$_redraw_color_hex" == *"1b5b3341"* ]] && \ + [[ "$_redraw_color_hex" != *"1b5b3441"* ]]; then + pass "T_arrow_redraw_stable[color]: styling preserves ESC[3A redraw geometry" +else + fail "T_arrow_redraw_stable[color]: redraw geometry changed (hex: ${_redraw_color_hex})" +fi + # --------------------------------------------------------------------------- # T_arrow_dispatch_a_lower_none_to_all_checked / T_arrow_dispatch_a_lower_mixed_to_all_checked / # T_arrow_dispatch_A_upper_all_checked_to_unchecked: diff --git a/tests/test_tui_pwsh.ps1 b/tests/test_tui_pwsh.ps1 index a8c1126..609fc67 100644 --- a/tests/test_tui_pwsh.ps1 +++ b/tests/test_tui_pwsh.ps1 @@ -7,7 +7,7 @@ # Usage: powershell -ExecutionPolicy Bypass -File tests\test_tui_pwsh.ps1 # PS 5.1 ASCII-only: no smart quotes, em-dashes, arrows, or emoji. # Cancel path (Esc/Q) and ReadKey failure are manual-only gates (no real key-reader seam). -# Test count: 10 +# Test count: 14 $ErrorActionPreference = 'Stop' $TestsPassed = 0 @@ -65,6 +65,107 @@ function Assert-ArrayEquals { # --------------------------------------------------------------------------- . $TuiLib +# =========================================================================== +# Quiet Accent styling tests +# =========================================================================== + +# --------------------------------------------------------------------------- +# T_quiet_accent_roles_ps: each semantic role maps to the selected console color. +# --------------------------------------------------------------------------- +Test-Scenario "T_quiet_accent_roles_ps: semantic roles map to Quiet Accent colors" { + $expected = @{ + Heading = [ConsoleColor]::Cyan + Focus = [ConsoleColor]::Cyan + Checked = [ConsoleColor]::Green + OptIn = [ConsoleColor]::Yellow + Confirmation = [ConsoleColor]::Green + Warning = [ConsoleColor]::Yellow + Cancel = [ConsoleColor]::Yellow + } + foreach ($role in $expected.Keys) { + $actual = Get-TuiRoleColor -Role $role + if ($actual -ne $expected[$role]) { + throw "$role expected $($expected[$role]), got $actual" + } + } +} + +# --------------------------------------------------------------------------- +# T_quiet_accent_plain_ps: forced plain and NO_COLOR suppress console styling. +# --------------------------------------------------------------------------- +Test-Scenario "T_quiet_accent_plain_ps: forced plain and NO_COLOR disable styling" { + $savedOverride = $env:_PS_TUI_COLOR_OVERRIDE + $savedNoColor = $env:NO_COLOR + try { + $env:_PS_TUI_COLOR_OVERRIDE = '0' + if (Test-TuiColorEnabled) { throw 'Forced-plain seam enabled styling' } + + $env:_PS_TUI_COLOR_OVERRIDE = '1' + $env:NO_COLOR = '1' + if (Test-TuiColorEnabled) { throw 'NO_COLOR did not override forced color' } + } + finally { + $env:_PS_TUI_COLOR_OVERRIDE = $savedOverride + $env:NO_COLOR = $savedNoColor + } +} + +# --------------------------------------------------------------------------- +# T_quiet_accent_redirected_ps: a piped child process must remain plain. +# --------------------------------------------------------------------------- +Test-Scenario "T_quiet_accent_redirected_ps: redirected output disables styling" { + $command = "& { . '$TuiLib'; if (Test-TuiColorEnabled) { exit 1 } }" + powershell -NoProfile -ExecutionPolicy Bypass -Command $command 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + throw 'Color styling remained enabled for redirected output' + } +} + +# --------------------------------------------------------------------------- +# T_quiet_accent_restores_ps: forced color applies roles and restores even when +# the output operation fails. +# --------------------------------------------------------------------------- +Test-Scenario "T_quiet_accent_restores_ps: foreground color restores on success and failure" { + $savedOverride = $env:_PS_TUI_COLOR_OVERRIDE + $savedWriter = $script:_TuiWriteOverride + try { + $env:_PS_TUI_COLOR_OVERRIDE = '1' + if (-not (Test-TuiColorEnabled)) { + throw 'Console color access is unavailable for forced-color seam' + } + + $before = [Console]::ForegroundColor + $script:_TuiWriteOverride = { + param($Text, $ErrorOutput) + $script:_TuiWriteCall = @($Text, $ErrorOutput) + } + Write-TuiStyled -Text 'role-check' -Color (Get-TuiRoleColor -Role 'Confirmation') + if ([Console]::ForegroundColor -ne $before) { + throw 'Foreground color did not restore after styled write' + } + + $script:_TuiWriteOverride = { + throw 'forced output failure' + } + $threw = $false + try { + Write-TuiStyled -Text 'restore-check' -Color (Get-TuiRoleColor -Role 'Warning') + } + catch { + $threw = $true + } + if (-not $threw) { throw 'Forced output failure did not propagate' } + if ([Console]::ForegroundColor -ne $before) { + throw 'Foreground color did not restore after output failure' + } + } + finally { + $script:_TuiWriteOverride = $savedWriter + Remove-Variable -Scope Script -Name _TuiWriteCall -ErrorAction SilentlyContinue + $env:_PS_TUI_COLOR_OVERRIDE = $savedOverride + } +} + # =========================================================================== # Resolve-FinalToolset unit tests # ===========================================================================