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. # Visible width of a markup cell. [Spectre.Console.Markup]::Remove collapses # whitespace-ONLY strings to '' (verified against the vendored 2.6.3) - a # blank mark slot measured as 0 gets extra padding, its column stretches, # and every other row gets unstyled fill: a black hole in the cursor-row # background right next to the bar (live-run feedback 2026-07-20 round 5). function script:Get-PSMMCellWidth { param([string]$Cell) if ([string]::IsNullOrWhiteSpace($Cell)) { return $Cell.Length } [Spectre.Console.Markup]::Remove($Cell).Length } # ONE list-table builder for every sub-screen, with the SAME cursor # treatment as the grid (mockup 2a): full-row rowbg background + a ▌ # accent bar in its own far-left slot + the caller's bold name cell. # Widths are computed from ALL rows and padding lives inside the cells, # so the background paints edge to edge. The grid builds its table # inline for speed but uses this exact technique; the design # consistency test holds both to it. function script:New-PSMMTable { param( [Parameter(Mandatory)][string[]]$Headers, [AllowEmptyCollection()]$Rows = @(), # one string[] of markup cells per row [int]$CursorRow = -1 # index into $Rows; -1 = no cursor ) $widths = @(foreach ($h in $Headers) { $h.Trim().Length }) foreach ($r in $Rows) { for ($ci = 0; $ci -lt $Headers.Count; $ci++) { $len = Get-PSMMCellWidth "$($r[$ci])" if ($len -gt $widths[$ci]) { $widths[$ci] = $len } } } # borderless grid inside a rounded panel (mockup 2a: outer frame only, # no column separators, no header rule). Column 0 is the cursor-bar # slot; the bar never shares a slot with content, so it can't cover # anything (live-run feedback 2026-07-20 round 4). $G = [Spectre.Console.Grid]::new() for ($ci = 0; $ci -le $Headers.Count; $ci++) { $col = [Spectre.Console.GridColumn]::new() $col.Padding = [Spectre.Console.Padding]::new(0, 0, 0, 0) $col.NoWrap = $true [void]$G.AddColumn($col) } $headerCells = [string[]](@(' ') + @(for ($ci = 0; $ci -lt $Headers.Count; $ci++) { $h = $Headers[$ci].Trim() if ($h) { " [$script:PSMM_ColDim]$($h.ToLowerInvariant())[/]" + (' ' * ([Math]::Max(0, $widths[$ci] - $h.Length) + 1)) } else { ' ' * ($widths[$ci] + 2) } })) [void][Spectre.Console.GridExtensions]::AddRow($G, $headerCells) for ($ri = 0; $ri -lt $Rows.Count; $ri++) { $isCur = ($ri -eq $CursorRow) $mark = if ($isCur) { " [$script:PSMM_ColAccent]$([char]0x258C)[/]" } else { ' ' } $cells = [string[]](@($mark) + @(for ($ci = 0; $ci -lt $Headers.Count; $ci++) { $cell = "$($Rows[$ri][$ci])" $len = Get-PSMMCellWidth $cell ' ' + $cell + (' ' * ([Math]::Max(0, $widths[$ci] - $len) + 1)) })) if ($isCur) { $cells = [string[]]@($cells | ForEach-Object { "[default on $script:PSMM_ColRowBg]$_[/]" }) } [void][Spectre.Console.GridExtensions]::AddRow($G, $cells) } $P = [Spectre.Console.Panel]::new($G) $P.Border = [Spectre.Console.BoxBorder]::Rounded $P.BorderStyle = Get-PSMMBorderStyle $P.Padding = [Spectre.Console.Padding]::new(0, 0, 0, 0) $P } # 1-based origin (@{ Top; Left }) for an overlay panel: dead centre of the # CONTENT area on both axes (live-run feedback rounds 2-4: bottom, # middle-left and window-centre all read as detached from the frame), # clamped to the top-left when the panel is bigger than the area. function script:Get-PSMMOverlayOrigin { param([int]$PanelHeight, [int]$PanelWidth, [int]$AreaHeight, [int]$AreaWidth) @{ Top = [Math]::Max(1, [Math]::Floor(($AreaHeight - $PanelHeight) / 2)) Left = [Math]::Max(1, [Math]::Floor(($AreaWidth - $PanelWidth) / 2)) } } # Visible size of a rendered frame: trailing blank lines dropped; the first # line is EXCLUDED from the width because it is the header bar, padded to # the full window width - including it would turn "centred over content" # back into "centred over the window". function script:Get-PSMMContentSize { param([Parameter(Mandatory)] $Renderable) $win = Get-PSMMWinSize $lines = @(ConvertTo-PSMMTextLines -Renderable $Renderable -Width $win.Width | ForEach-Object { "$_".TrimEnd() }) while ($lines.Count -gt 0 -and [string]::IsNullOrEmpty($lines[$lines.Count - 1])) { $lines = @($lines | Select-Object -First ($lines.Count - 1)) } $w = 0 for ($i = 1; $i -lt $lines.Count; $i++) { if ($lines[$i].Length -gt $w) { $w = $lines[$i].Length } } if ($w -le 0) { $w = $win.Width } @{ Height = [Math]::Max(1, $lines.Count); Width = $w } } # Draw a renderable ON TOP of the current frame via raw VT cursor # positioning - Spectre's live display has no z-layers, and appending the # panel below a full-height frame pushed the screen (live-run feedback). # The panel floats dead centre over the CONTENT ($Content = the frame's # renderable, measured via Get-PSMMContentSize; window when omitted). # DECSC/DECRC keep the live display's cursor bookkeeping intact; the # caller erases the region (Clear-PSMMOverlay) before the next repaint. # Returns the drawn region (@{ Top; Left; Count; Width }) or $null when # output is redirected. function script:Write-PSMMOverlay { [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Raw VT cursor positioning must bypass any host/stream formatting.')] param( [Parameter(Mandatory)] $Renderable, $Content ) try { if ([Console]::IsOutputRedirected) { return $null } $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]::Yes $settings.ColorSystem = [Spectre.Console.ColorSystemSupport]::EightBit $console = [Spectre.Console.AnsiConsole]::Create($settings) $console.Profile.Width = [Math]::Max(40, (Get-PSMMWinSize).Width - 2) $console.Write($Renderable) $lines = @($sw.ToString() -split "`r?`n" | Where-Object { $_ -ne '' }) if (-not $lines.Count) { return $null } $win = Get-PSMMWinSize $panelWidth = 0 foreach ($l in $lines) { $w = ($l -replace '\x1b\[[0-9;?]*[A-Za-z]', '').Length if ($w -gt $panelWidth) { $panelWidth = $w } } $area = if ($Content) { Get-PSMMContentSize -Renderable $Content } else { @{ Height = $win.Height; Width = $win.Width } } $origin = Get-PSMMOverlayOrigin -PanelHeight $lines.Count -PanelWidth $panelWidth -AreaHeight $area.Height -AreaWidth $area.Width $esc = [char]27 $out = [System.Text.StringBuilder]::new() [void]$out.Append("$esc" + '7') # DECSC save cursor for ($i = 0; $i -lt $lines.Count; $i++) { [void]$out.Append("$esc[$($origin.Top + $i);$($origin.Left)H").Append($lines[$i]).Append("$esc[0m") } [void]$out.Append("$esc" + '8') # DECRC restore [Console]::Write($out.ToString()) @{ Top = $origin.Top; Left = $origin.Left; Count = $lines.Count; Width = $panelWidth } } catch { $null } } # Erase the rectangle an overlay was drawn in (blank spaces, not whole # lines - a centred panel must not wipe the frame text beside it); the # caller's next repaint restores the content underneath. function script:Clear-PSMMOverlay { [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Raw VT cursor positioning must bypass any host/stream formatting.')] param($Region) if (-not $Region) { return } try { if ([Console]::IsOutputRedirected) { return } $esc = [char]27 $blank = ' ' * [Math]::Max(1, [int]$Region.Width) $left = [Math]::Max(1, [int]$Region.Left) $out = [System.Text.StringBuilder]::new() [void]$out.Append("$esc" + '7') for ($i = 0; $i -lt $Region.Count; $i++) { [void]$out.Append("$esc[$($Region.Top + $i);${left}H").Append("$esc[0m").Append($blank) } [void]$out.Append("$esc" + '8') [Console]::Write($out.ToString()) } catch { } } # 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) { # ?25l: hide the console cursor for the TUI - it blinked over the # frames (live-run feedback); text prompts show it again [Console]::Write("$([char]27)[?1049h$([char]27)[H$([char]27)[?25l") $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)[?25h$([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, [int]$Width = 0 ) $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 = if ($Width -gt 0) { $Width } else { (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 } } # ONE key capsule (§3): reverse-video key block on the capsule background. # Every rendering of a key - hint rows, help tabs, prose - goes through here, # so the capsule is defined exactly once. Keys are always lowercase, and # multi-key names are spelled out ('left/right', 'up/dn', 'pgup/pgdn'), never # drawn as arrow glyphs: the arrows are reserved for the scroll indicator # (design system §9, gh#7). function script:Get-PSMMKeyCap { param([Parameter(Mandatory)][string]$Key) "[$script:PSMM_ColKey on $script:PSMM_ColCapsule] $($Key.ToLowerInvariant()) [/]" } # Render "key=action" pairs as one consistently-styled hint line. # Design system v2 (§3): every key is a capsule, mute label, two-space # separator. '^' 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 "$(Get-PSMMKeyCap -Key $k) [$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. A blank # line above separates it from the verb rows (mockup 2a) - it is part of # the returned markup so every screen gets it for free. 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[/]") } "`n" + ($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-PSMMUpkeepWord { 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. # psmm's own modules read as infrastructure (◈, dim) rather than as something # you asked for (gh#16) - but only once they are actually present: a MISSING # dependency is a real problem and must still say so. function script:Get-PSMMStateMarkup { param([Parameter(Mandatory)] $Entry) if ($Entry.PSObject.Properties['Unmanaged']) { return "[$script:PSMM_ColInfo]$([char]0x25CC) unmanaged[/]" } if ((Test-PSMMOwnModule -Name $Entry.Name) -and ($Entry.Loaded -or $Entry.Installed)) { return "[$script:PSMM_ColDim]$([char]0x25C8) psmm's own[/]" } 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) } # Minimal single-line text prompt: enter accepts (empty input returns the # default when one is set), ESC CANCELS and returns $null - the Spectre # prompts have no abort path (live-run feedback). The console cursor is # shown for the duration (hidden otherwise in the TUI). function script:Read-PSMMText { [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'A raw key-by-key line editor (esc-cancel) needs direct console echo.')] param( [Parameter(Mandatory)][string]$Message, [string]$DefaultAnswer = '', [switch]$AllowEmpty ) $hint = if ($DefaultAnswer) { " [$script:PSMM_ColDim]($(ConvertTo-PSMMSafe $DefaultAnswer))[/]" } else { '' } Write-PSMMRenderable ([Spectre.Console.Markup]::new( "[$script:PSMM_ColMute]$(ConvertTo-PSMMSafe $Message)[/]$hint[$script:PSMM_ColMute]:[/] [$script:PSMM_ColDim](esc cancels)[/] ")) $buf = [System.Text.StringBuilder]::new() try { try { [Console]::Write("$([char]27)[?25h") } catch { } while ($true) { $k = [Console]::ReadKey($true) if ($k.Key -eq [ConsoleKey]::Escape) { [Console]::WriteLine(); return $null } if ($k.Key -eq [ConsoleKey]::Enter) { [Console]::WriteLine() $text = $buf.ToString() if (-not $text -and $DefaultAnswer) { return $DefaultAnswer } if (-not $text -and -not $AllowEmpty) { return $null } # nothing to accept return $text } if ($k.Key -eq [ConsoleKey]::Backspace) { if ($buf.Length) { $buf.Length--; [Console]::Write("`b `b") } continue } if ($k.KeyChar -and -not [char]::IsControl($k.KeyChar)) { [void]$buf.Append($k.KeyChar) [Console]::Write($k.KeyChar) } } } finally { try { [Console]::Write("$([char]27)[?25l") } catch { } } } # 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 } |