src/UI/04-Render.ps1
|
# 04-Render.ps1 — the cross-cutting rendering primitives every screen must go # through (design system §11): code and commands, links, prose, versions. # # The rule these exist to enforce: a screen never hand-formats one of these # four things. Before this file, code was flat text, one command was # hand-coloured cyan, URLs were dead text and prose ran the full terminal # width. Colours come from the theme tokens only (00-Theme.ps1), so all three # themes keep working and the palette guard test stays green. # --- prose ---------------------------------------------------------------- # The measure prose wraps at: never wider than the window (less a margin), # never wider than ~84 columns, because a paragraph running a 200-column # terminal edge to edge is unreadable (gh#11). function script:Get-PSMMProseWidth { param([int]$Max = 84, [int]$Margin = 4) $win = Get-PSMMWinSize [Math]::Max(24, [Math]::Min($Max, $win.Width - $Margin)) } # Word-wrap PLAIN text (no markup - see Get-PSMMProseMarkup for the styled # version). Existing line breaks are kept; blank lines survive as blank lines. # A single token longer than the measure is NOT broken: a hard-broken URL or # path stops being copyable, which is worse than one over-long line. Long # paths in the UI go through Get-PSMMTrunc instead. function script:Get-PSMMWrapText { param( [Parameter(Mandatory)][AllowEmptyString()][string]$Text, [int]$Width = 0 ) if ($Width -le 0) { $Width = Get-PSMMProseWidth } $out = [System.Collections.Generic.List[string]]::new() foreach ($para in ("$Text" -split "`r?`n")) { if ([string]::IsNullOrWhiteSpace($para)) { $out.Add(''); continue } # keep the paragraph's own leading indent on continuation lines $indent = '' if ($para -match '^(\s+)') { $indent = $Matches[1] } $line = '' foreach ($w in ($para -split '\s+' | Where-Object { $_ })) { if (-not $line) { $line = "$indent$w"; continue } if (($line.Length + 1 + $w.Length) -le $Width) { $line += " $w" } else { $out.Add($line); $line = "$indent$w" } } if ($line) { $out.Add($line) } } @($out) } # Wrap a filesystem path at SEPARATOR boundaries. Neither of the two obvious # options works for a path: word wrap does nothing (a path is one long token, # so the grid reflows it into ragged nonsense and blows the label column out), # and truncation throws away the tail - which is the half that says WHICH # module and WHICH version. Breaking after a '\' keeps every character and # stays readable. function script:Get-PSMMWrapPath { param( [Parameter(Mandatory)][AllowEmptyString()][string]$Path, [int]$Width = 0 ) if ($Width -le 0) { $Width = Get-PSMMProseWidth } $Width = [Math]::Max(12, $Width) if ([string]::IsNullOrEmpty($Path)) { return @('') } if ($Path.Length -le $Width) { return @($Path) } # each separator stays attached to the segment before it, so a wrapped # path still reads as a path $parts = @([regex]::Matches($Path, '[^\\/]*[\\/]?') | ForEach-Object { $_.Value } | Where-Object { $_ -ne '' }) $lines = [System.Collections.Generic.List[string]]::new() $cur = '' foreach ($part in $parts) { $p = $part if ($cur -and ($cur.Length + $p.Length) -gt $Width) { $lines.Add($cur); $cur = '' } # one segment longer than the measure: hard-split it, there is no # better break point inside a single folder name while ($p.Length -gt $Width) { if ($cur) { $lines.Add($cur); $cur = '' } $lines.Add($p.Substring(0, $Width)) $p = $p.Substring($Width) } $cur += $p } if ($cur) { $lines.Add($cur) } @($lines) } # Wrapped, escaped, styled prose as one markup string per line. Each line # carries its own balanced tags, which is mandatory: every Markup/Write-PSMMLine # is parsed on its own (there is a test for it). function script:Get-PSMMProseMarkup { param( [Parameter(Mandatory)][AllowEmptyString()][string]$Text, [string]$Colour, [int]$Width = 0 ) $c = if ($Colour) { $Colour } else { $script:PSMM_ColMute } @(Get-PSMMWrapText -Text $Text -Width $Width | ForEach-Object { if ($_) { "[$c]$(ConvertTo-PSMMSafe $_)[/]" } else { '' } }) } # Same, written straight to the console (full-screen prompt flows). function script:Write-PSMMProse { param( [Parameter(Mandatory)][AllowEmptyString()][string]$Text, [string]$Colour, [int]$Width = 0 ) foreach ($l in (Get-PSMMProseMarkup -Text $Text -Colour $Colour -Width $Width)) { Write-PSMMLine $l } } # --- links ---------------------------------------------------------------- # A real terminal hyperlink (OSC 8) via Spectre's [link=...] style, so the URL # is ctrl+clickable in Windows Terminal and friends (gh#10). Falls back to # plain styled text for anything that cannot be expressed safely as a link - # markup delimiters or whitespace in the URL would break the tag. function script:Get-PSMMLinkMarkup { param( [Parameter(Mandatory)][string]$Url, [string]$Text, [string]$Colour ) $c = if ($Colour) { $Colour } else { $script:PSMM_ColInfo } $label = ConvertTo-PSMMSafe $(if ($Text) { $Text } else { $Url }) if ($Url -match '[\[\]\s]' -or [string]::IsNullOrWhiteSpace($Url)) { return "[$c]$label[/]" } "[$c link=$Url]$label[/]" } # Does this string look like something worth linking? function script:Test-PSMMUrl { param([string]$Text) "$Text" -match '^(https?|ftp)://[^\s\[\]]+$' } # --- code and commands ---------------------------------------------------- # Token kind -> theme token. PSParser's type names, mapped once. function script:Get-PSMMCodeColour { param([string]$Kind) switch ($Kind) { 'Command' { $script:PSMM_ColAccent } 'CommandParameter' { $script:PSMM_ColKey } 'Keyword' { $script:PSMM_ColKey } 'String' { $script:PSMM_ColOk } 'Variable' { $script:PSMM_ColInfo } 'Type' { $script:PSMM_ColInfo } 'Attribute' { $script:PSMM_ColInfo } 'Comment' { $script:PSMM_ColDim } 'Number' { $script:PSMM_ColWarn } 'Operator' { $script:PSMM_ColMute } 'StatementSeparator' { $script:PSMM_ColMute } 'GroupStart' { $script:PSMM_ColMute } 'GroupEnd' { $script:PSMM_ColMute } 'Member' { $script:PSMM_ColMute } default { '' } } } # Highlight one JSON line. Regex rather than a parser because the JSON psmm # shows is a documentation sample with // comments - not valid JSON, so no # parser would take it. Order matters: strings win over comments, so a '//' # inside a quoted value stays a string. function script:Format-PSMMJsonLine { param([AllowEmptyString()][string]$Line) $rx = '(?<key>"(?:[^"\\]|\\.)*")(?=\s*:)|(?<str>"(?:[^"\\]|\\.)*")|(?<comment>//.*$)|(?<lit>\b(?:true|false|null)\b)|(?<num>-?\b\d+(?:\.\d+)?\b)' $out = [System.Text.StringBuilder]::new() $pos = 0 foreach ($m in [regex]::Matches("$Line", $rx)) { if ($m.Index -gt $pos) { [void]$out.Append((ConvertTo-PSMMSafe $Line.Substring($pos, $m.Index - $pos))) } $colour = if ($m.Groups['key'].Success) { $script:PSMM_ColAccent } elseif ($m.Groups['str'].Success) { $script:PSMM_ColOk } elseif ($m.Groups['comment'].Success) { $script:PSMM_ColDim } elseif ($m.Groups['lit'].Success) { $script:PSMM_ColKey } else { $script:PSMM_ColWarn } [void]$out.Append("[$colour]$(ConvertTo-PSMMSafe $m.Value)[/]") $pos = $m.Index + $m.Length } if ($pos -lt "$Line".Length) { [void]$out.Append((ConvertTo-PSMMSafe $Line.Substring($pos))) } $out.ToString() } # Syntax-highlighted code as one markup string per input line (gh#9). # PowerShell goes through PSParser, which hands back typed tokens with offsets # into the whole snippet - accurate, in-box, no dependency. Colours are applied # per CHARACTER and re-run per line, so a token that spans a newline (comment # block, here-string) still leaves every emitted line individually balanced. # Unparseable input degrades to escaped plain text rather than throwing. function script:Format-PSMMCode { param( [Parameter(Mandatory)][AllowEmptyString()][AllowEmptyCollection()][string[]]$Text, [ValidateSet('powershell', 'json')][string]$Language = 'powershell' ) $lines = @(("$($Text -join "`n")") -split "`r?`n") if ($Language -eq 'json') { return @($lines | ForEach-Object { Format-PSMMJsonLine -Line $_ }) } $src = $lines -join "`n" if (-not $src) { return @($lines | ForEach-Object { ConvertTo-PSMMSafe $_ }) } $colours = [string[]]::new($src.Length) try { $errs = $null foreach ($t in [System.Management.Automation.PSParser]::Tokenize($src, [ref]$errs)) { $c = Get-PSMMCodeColour -Kind "$($t.Type)" if (-not $c) { continue } $end = [Math]::Min($src.Length, $t.Start + $t.Length) for ($i = [Math]::Max(0, $t.Start); $i -lt $end; $i++) { $colours[$i] = $c } } } catch { return @($lines | ForEach-Object { ConvertTo-PSMMSafe $_ }) } $out = [System.Collections.Generic.List[string]]::new() $pos = 0 foreach ($line in $lines) { $sb = [System.Text.StringBuilder]::new() $i = 0 while ($i -lt $line.Length) { $c = $colours[$pos + $i] $j = $i while ($j -lt $line.Length -and $colours[$pos + $j] -eq $c) { $j++ } $chunk = ConvertTo-PSMMSafe $line.Substring($i, $j - $i) if ($c) { [void]$sb.Append("[$c]$chunk[/]") } else { [void]$sb.Append($chunk) } $i = $j } $out.Add($sb.ToString()) $pos += $line.Length + 1 # +1 for the newline the lines were joined with } @($out) } # One command, highlighted, for inline use in a sentence. function script:Get-PSMMCommandMarkup { param([Parameter(Mandatory)][string]$Command) @(Format-PSMMCode -Text @($Command) -Language powershell) -join ' ' } # --- versions ------------------------------------------------------------- # A version cell: base version in the caller's colour, prerelease label # attached and tinted so "0.1.0-beta8" can never be mistaken for "0.1.0" # (gh#6). Returns escaped markup. function script:Get-PSMMVersionMarkup { param( $Version, [string]$Prerelease, [string]$Colour, [string]$Empty = '-' ) if ($null -eq $Version -or "$Version" -eq '') { return "[$script:PSMM_ColDim]$Empty[/]" } $base = ConvertTo-PSMMSafe "$Version" $txt = if ($Colour) { "[$Colour]$base[/]" } else { $base } if ([string]::IsNullOrWhiteSpace($Prerelease)) { return $txt } "$txt[$script:PSMM_ColInfo]-$(ConvertTo-PSMMSafe ($Prerelease.TrimStart('-')))[/]" } # Plain "1.2.0-beta3" for prose and status lines. function script:Get-PSMMVersionText { param($Version, [string]$Prerelease) Get-PSMMVersionDisplay -Version $Version -Prerelease $Prerelease } |