src/UI/00-Theme.ps1
|
# 00-Theme.ps1 — palette, markup helpers, console plumbing, alternate screen. # # Palette (2026-07-06 refresh, Claude-Code-inspired warm scheme; the blue # accent is the original look and stays): # key = keyboard shortcuts (coral) # mute = action labels / separators / hints (bright enough to read!) # accent = cursor row, titles # Status colours across the UI are explicit 256-colour names (green3 / # orange1 / indianred1 / steelblue1) so they render identically in every # terminal instead of following the terminal's ANSI scheme. $script:PSMM_ColKey = 'salmon1' $script:PSMM_ColMute = 'grey66' $script:PSMM_ColAccent = 'deepskyblue1' # The console every UI write goes through. Production: the real AnsiConsole. # Tests inject a StringWriter-backed console via Set-PSMMConsole and assert on # the rendered frames (see D-UI-ARCH). function script:Get-PSMMConsole { if (-not $script:PSMM_Console) { $script:PSMM_Console = [Spectre.Console.AnsiConsole]::Console } $script:PSMM_Console } function script:Set-PSMMConsole { param($Console) $script:PSMM_Console = $Console } # Write one markup line through the psmm console. function script:Write-PSMMLine { param([string]$Markup = '') (Get-PSMMConsole).Write([Spectre.Console.Markup]::new($Markup + "`n")) } function script:Write-PSMMRenderable { param([Parameter(Mandatory)] $Renderable) (Get-PSMMConsole).Write($Renderable) } function script:Clear-PSMMScreen { # MUST be Clear($true): IAnsiConsole's zero-arg Clear() is a C# extension # method PowerShell cannot see, so .Clear() throws and the catch made every # "clear" a silent no-op - sub-screens appended below the grid instead of # replacing it (2026-07-05 live-run bug). try { (Get-PSMMConsole).Clear($true) } catch { } } # Run a live display on the psmm console. $Body is param($ctx) { ... } and # runs synchronously in module scope (verified - see D-UI-ARCH). function script:Invoke-PSMMLive { param( [Parameter(Mandatory)][scriptblock]$Body, $Initial ) if (-not $Initial) { $Initial = [Spectre.Console.Markup]::new(' ') } $live = [Spectre.Console.LiveDisplay]::new((Get-PSMMConsole), $Initial) $live.Start([Action[Spectre.Console.LiveDisplayContext]]$Body) } # --- alternate screen buffer (#4: preserve the user's scrollback) --------- # Entering flips the terminal to the alt buffer exactly like edit/vim/less; # leaving restores the previous screen contents untouched. No-ops when # output is redirected (tests, CI). function script:Enter-PSMMAltScreen { [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Raw VT escape for the alternate screen buffer must bypass any host/stream formatting.')] param() try { if (-not [Console]::IsOutputRedirected) { [Console]::Write("$([char]27)[?1049h$([char]27)[H") $script:PSMM_AltScreenActive = $true } } catch { } } function script:Exit-PSMMAltScreen { [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Raw VT escape for the alternate screen buffer must bypass any host/stream formatting.')] param() try { if ($script:PSMM_AltScreenActive) { [Console]::Write("$([char]27)[?1049l") $script:PSMM_AltScreenActive = $false } } catch { } } # Render any renderable to plain text lines at the current window width. # Used to feed tables into the shared pager: inside the alternate screen # buffer there is no scrollback, so anything potentially tall must scroll. function script:ConvertTo-PSMMTextLines { param([Parameter(Mandatory)] $Renderable) $sw = [System.IO.StringWriter]::new() $settings = [Spectre.Console.AnsiConsoleSettings]::new() $settings.Out = [Spectre.Console.AnsiConsoleOutput]::new($sw) $settings.Interactive = [Spectre.Console.InteractionSupport]::No $settings.Ansi = [Spectre.Console.AnsiSupport]::No $console = [Spectre.Console.AnsiConsole]::Create($settings) $console.Profile.Width = (Get-PSMMWinSize).Width - 4 $console.Write($Renderable) # Ansi=No is NOT always honoured: Spectre's environment detection (e.g. # GITHUB_ACTIONS=true) force-enables ANSI over the explicit setting, and # escape codes here would end up as literal garbage in the pager. Strip. (($sw.ToString() -replace '\x1b\[[0-9;?]*[A-Za-z]', '') -split "`r?`n") } # --- small shared formatting helpers -------------------------------------- # Fast markup escape for the hot render path (avoids a cmdlet call per cell). function script:ConvertTo-PSMMSafe { param([string]$s) if ($null -eq $s) { '' } else { $s.Replace('[', '[[').Replace(']', ']]') } } # Truncate with an ellipsis so rows never wrap to a second line. function script:Get-PSMMTrunc { param([string]$s, [int]$max) if ($null -eq $s) { return '' } if ($s.Length -le $max) { return $s } if ($max -le 1) { return $s.Substring(0, [Math]::Max(0, $max)) } $s.Substring(0, $max - 1) + [char]0x2026 # … } # Console size, defaulting safely when there is no real console. function script:Get-PSMMWinSize { $h = 25; $w = 120 try { $h = [Console]::WindowHeight; $w = [Console]::WindowWidth } catch { } if ($h -le 0) { $h = 25 } if ($w -le 0) { $w = 120 } [pscustomobject]@{ Height = $h; Width = $w } } # Render "key=action" pairs as one consistently-styled hint line. function script:Get-PSMMHint { param([Parameter(Mandatory)][string[]]$Pairs) $parts = foreach ($p in $Pairs) { $k, $v = $p -split '=', 2 "[$script:PSMM_ColKey]$k[/] [$script:PSMM_ColMute]$v[/]" } $parts -join " [$script:PSMM_ColMute]·[/] " } # Two-column label/value grid for detail panels. function script:New-PSMMDetailGrid { param([Parameter(Mandatory)] $Rows) # array of @(label, valueMarkup) $g = [Spectre.Console.Grid]::new() [void]$g.AddColumn([Spectre.Console.GridColumn]::new()) [void]$g.AddColumn([Spectre.Console.GridColumn]::new()) foreach ($r in $Rows) { [void][Spectre.Console.GridExtensions]::AddRow($g, [string[]]@("[$script:PSMM_ColAccent]$($r[0])[/]", $r[1])) } $g } |