src/UI/00-Theme.ps1
|
# 00-Theme.ps1 — palette, markup helpers, console plumbing, alternate screen. # # The palette (docs/design-system-v2.md §1) is read from the shared token # table in src/Engine/Theme.ps1, so the $PSMM_Theme knob (glacier | ember | # moss) swaps every token in one place and the startup report renders the # same colours without Spectre. Explicit 256-colour names render identically # in every terminal. Nothing outside the theme sources may name a colour. $psmmThemeTable = Get-PSMMThemeTable $script:PSMM_ColKey = $psmmThemeTable['key'].Markup $script:PSMM_ColMute = $psmmThemeTable['mute'].Markup $script:PSMM_ColAccent = $psmmThemeTable['accent'].Markup $script:PSMM_ColOk = $psmmThemeTable['ok'].Markup $script:PSMM_ColWarn = $psmmThemeTable['warn'].Markup $script:PSMM_ColErr = $psmmThemeTable['err'].Markup $script:PSMM_ColInfo = $psmmThemeTable['info'].Markup $script:PSMM_ColDim = $psmmThemeTable['dim'].Markup # de-emphasised cells, legends $script:PSMM_ColCapsule = $psmmThemeTable['capsule'].Markup # key-capsule background $script:PSMM_ColRowBg = $psmmThemeTable['rowbg'].Markup # cursor-row background $script:PSMM_ColBorder = $psmmThemeTable['border'].Markup # ALL table/panel borders $script:PSMM_ColBrandFg = $psmmThemeTable['brandfg'].Markup # the ' psmm ' brand block $script:PSMM_ColBrandBg = $psmmThemeTable['brandbg'].Markup $script:PSMM_ColCapsuleDim = $psmmThemeTable['capsdim'].Markup # persistent-row capsule background # Shared border style: every table/panel border goes through this so the # border colour is themed in one place. function script:Get-PSMMBorderStyle { [Spectre.Console.Style]::Parse($script:PSMM_ColBorder) } # Standard table chrome (§1/§5): rounded border in the border token, # lowercase dim headers. Screens add rows themselves. function script:New-PSMMTable { param([Parameter(Mandatory)][string[]]$Headers) $T = [Spectre.Console.Table]::new() $T.Border = [Spectre.Console.TableBorder]::Rounded $T.BorderStyle = Get-PSMMBorderStyle foreach ($h in $Headers) { $cell = if ($h.Trim()) { "[$script:PSMM_ColDim]$($h.ToLowerInvariant())[/]" } else { $h } [void][Spectre.Console.TableExtensions]::AddColumn($T, $cell) } $T } # Cursor marker for simple (non-grid) list tables: the v2 block bar. function script:Get-PSMMCursorMark { param([bool]$IsCursor) if ($IsCursor) { "[$script:PSMM_ColAccent]$([char]0x258C)[/]" } else { ' ' } } # 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. # Design system v2 (§3): every key is a capsule - reverse-video key block on # the capsule background, mute label, two-space separator. Keys are always # lowercase; '^' before a key means ctrl, and any line using a '^' chord # carries the dim '^ = ctrl' legend at the end of the row. function script:Get-PSMMHint { param( [Parameter(Mandatory)][string[]]$Pairs, # rows that sit above a persistent strip (which carries the legend # itself) suppress their own to avoid repeating it [switch]$NoLegend ) $parts = foreach ($p in $Pairs) { $k, $v = $p -split '=', 2 "[$script:PSMM_ColKey on $script:PSMM_ColCapsule] $($k.ToLowerInvariant()) [/] [$script:PSMM_ColMute]$v[/]" } if (-not $NoLegend -and @($Pairs | Where-Object { ($_ -split '=', 2)[0] -match '\^' }).Count) { $parts = @($parts) + @("[$script:PSMM_ColDim]^ = ctrl[/]") } $parts -join ' ' } # Tier-two hint row (§3): the persistent strip - always the same keys, # always last, accent keys on the darker capsule with dim labels. function script:Get-PSMMPersistentHint { param([string[]]$Pairs = @("g=goto$([char]0x2026)", '/=filter', '?=help', '^q=quit')) $parts = foreach ($p in $Pairs) { $k, $v = $p -split '=', 2 "[$script:PSMM_ColAccent on $script:PSMM_ColCapsuleDim] $($k.ToLowerInvariant()) [/] [$script:PSMM_ColDim]$v[/]" } if (@($Pairs | Where-Object { ($_ -split '=', 2)[0] -match '\^' }).Count) { $parts = @($parts) + @("[$script:PSMM_ColDim]^ = ctrl[/]") } $parts -join ' ' } # One-line header bar (§2), every screen: brand block + breadcrumb (+ dim # counts) with version · engine · elevated · ⇡ update right-aligned. Returns # a markup string padded to the window width. function script:Get-PSMMHeaderBar { param( [string[]]$Breadcrumb = @('home'), [string]$CountsMarkup, [string]$RightMarkup ) $ui = $script:PSMM_UI $crumbs = @(for ($i = 0; $i -lt $Breadcrumb.Count; $i++) { if ($i -lt $Breadcrumb.Count - 1) { "[$script:PSMM_ColDim]$(ConvertTo-PSMMSafe $Breadcrumb[$i]) $([char]0x203A)[/]" } else { "[default]$(ConvertTo-PSMMSafe $Breadcrumb[$i])[/]" } }) $left = "[$script:PSMM_ColBrandFg on $script:PSMM_ColBrandBg] psmm [/] " + ($crumbs -join ' ') if ($CountsMarkup) { $left += " $CountsMarkup" } $right = if ($PSBoundParameters.ContainsKey('RightMarkup')) { $RightMarkup } else { $parts = @() if ($ui -and $ui.Version) { $parts += "v$($ui.Version)" } if ($ui -and $ui.Engine) { $parts += "$($ui.Engine)" } if ($ui -and $ui.Elevated) { $parts += 'elevated' } $r = "[$script:PSMM_ColDim]$($parts -join " $([char]0x00B7) ")[/]" if ($ui -and $ui.SelfUpdate) { $r += " [$script:PSMM_ColWarn]$([char]0x21E1) update[/]" } $r } $lLen = [Spectre.Console.Markup]::Remove($left).Length $rLen = [Spectre.Console.Markup]::Remove($right).Length $pad = [Math]::Max(1, (Get-PSMMWinSize).Width - $lLen - $rLen - 1) # full-width bar background (§2); inner tags override only the foreground "[default on $script:PSMM_ColCapsuleDim]" + $left + (' ' * $pad) + $right + "[/]" } # v2 display language (§5): the JSON schema keeps the Mode/Install enums; # these are the plain words the UI shows for them. function script:Get-PSMMStartupWord { param($Mode) switch ("$Mode") { 'Load' { 'load' } 'InstallOnly' { 'install' } 'Ignore' { 'off' } default { '-' } } } function script:Get-PSMMGalleryWord { param($Install) switch ("$Install") { 'IfMissing' { 'if-missing' } 'CheckOnly' { 'check-only' } 'Latest' { 'latest' } default { '-' } } } # State glyph + word (§5): glyph and word travel together, never glyph alone. function script:Get-PSMMStateMarkup { param([Parameter(Mandatory)] $Entry) if ($Entry.PSObject.Properties['Unmanaged']) { return "[$script:PSMM_ColInfo]$([char]0x25CC) unmanaged[/]" } if ($Entry.Loaded) { return "[$script:PSMM_ColOk]$([char]0x25CF) loaded[/]" } if ($Entry.Installed) { return "[$script:PSMM_ColWarn]$([char]0x25D0) installed[/]" } "[$script:PSMM_ColErr]$([char]0x25CB) missing[/]" } # Minimum terminal size a table screen needs; below it Spectre collapses the # table to '...'. Screens render Get-PSMMTooSmallView instead of the table. function script:Test-PSMMWinTooSmall { param([int]$MinWidth = 60, [int]$MinHeight = 14) $win = Get-PSMMWinSize ($win.Width -lt $MinWidth) -or ($win.Height -lt $MinHeight) } function script:Get-PSMMTooSmallView { param([int]$MinWidth = 60, [int]$MinHeight = 14) $win = Get-PSMMWinSize $items = [System.Collections.Generic.List[Spectre.Console.Rendering.IRenderable]]::new() $items.Add([Spectre.Console.Markup]::new("[$script:PSMM_ColWarn]window too small to draw this screen[/]")) $items.Add([Spectre.Console.Markup]::new("[$script:PSMM_ColMute]current $($win.Width)x$($win.Height), need at least ${MinWidth}x${MinHeight} - enlarge the terminal[/]")) $items.Add([Spectre.Console.Markup]::new((Get-PSMMHint -Pairs @('esc=back', '^q=quit')))) [Spectre.Console.Rows]::new($items) } # 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 } |