lib/creatorengine.ps1
|
# The pages and keys of `tstyles create`: a pure engine over the creator state # (lib/creator.ps1). # # Invoke-CreatorKey takes the state and one key, and returns the state and what # the key loop should do next. Get-CreatorPageLines returns the page as # strings: the controls, then a fixed sample session. Neither writes to the # console or to a file, and neither reads the console: the loop in # Invoke-TerminalStyleCreate does all of that. The image reader is injected, # so every page runs and is tested on every platform. # # A separate file from lib/creator.ps1 only for size: the state and the # preview live there, the page logic here. # # ENCODING: ASCII-only literals. Windows PowerShell 5.1 reads a BOM-less .ps1 # in the system codepage, so every non-ASCII glyph is built from its code point. $script:CreatorStepTitles = @('Background', 'Colours', 'Font and cursor', 'Prompt', 'Name and save') # Windows Terminal's values for backgroundImageStretchMode and # backgroundImageAlignment, in the order Left/Right cycles them. $script:CreatorStretchModes = @('none', 'fill', 'uniform', 'uniformToFill') $script:CreatorAlignments = @('center', 'left', 'top', 'right', 'bottom', 'topLeft', 'topRight', 'bottomLeft', 'bottomRight') $script:CreatorCursorShapes = @('bar', 'vintage', 'underscore', 'filledBox', 'emptyBox', 'doubleUnderscore') # Monospace faces that come with Windows or with Windows Terminal itself, so a # preview never names a font the machine lacks. $script:CreatorFontFaces = @('Cascadia Code', 'Cascadia Mono', 'Consolas', 'Lucida Console', 'Courier New') $script:CreatorFontSizeMin = 6 $script:CreatorFontSizeMax = 36 function ConvertFrom-PastedPath { <# .SYNOPSIS A typed or pasted path, without surrounding whitespace and one pair of quotes. .DESCRIPTION Windows Explorer's "Copy as path" wraps the path in double quotes, and some shells quote a dragged-in path with single quotes. One matching pair is removed, then the inside is trimmed; an unmatched quote is left alone. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory, Position = 0)][AllowEmptyString()][AllowNull()][string]$Text) if ($null -eq $Text) { return '' } $t = $Text.Trim() if ($t.Length -ge 2) { $first = $t[0]; $last = $t[$t.Length - 1] if (($first -eq '"' -and $last -eq '"') -or ($first -eq "'" -and $last -eq "'")) { $t = $t.Substring(1, $t.Length - 2).Trim() } } $t } function New-CreatorStartState { <# .SYNOPSIS The creator before an image: step 0, an empty path field, no scheme. .DESCRIPTION Step 0 exists before New-CreatorState can be called, because the palette comes from the image. This holds only what that page needs. The first Enter on a readable image replaces it with New-CreatorState's full state (Invoke-CreatorKey), still on step 0 so the image settings appear; the next Enter moves to step 1. #> [CmdletBinding()] [OutputType([hashtable])] param() @{ Step = 0 PathText = '' Control = 0 HexEntry = $null ImagePath = $null Scheme = $null } } function Get-CreatorColorSlots { # The scheme's colour slots, in the order the colours page lists them: # every key of scheme.json except its name. [OutputType([string[]])] param() [string[]]@( 'background', 'foreground', 'cursorColor', 'selectionBackground', 'black', 'red', 'green', 'yellow', 'blue', 'purple', 'cyan', 'white', 'brightBlack', 'brightRed', 'brightGreen', 'brightYellow', 'brightBlue', 'brightPurple', 'brightCyan', 'brightWhite' ) } function Initialize-CreatorEngineState { # The engine's own keys, added when a state from New-CreatorState lacks # them: the selected control, the path field's text, the hex entry. param([Parameter(Mandatory)][hashtable]$State) if (-not $State.ContainsKey('Control') -or $null -eq $State.Control) { $State.Control = 0 } if (-not $State.ContainsKey('PathText') -or $null -eq $State.PathText) { $State.PathText = '' if ($State.ImagePath) { $State.PathText = [string]$State.ImagePath } } if (-not $State.ContainsKey('HexEntry')) { $State.HexEntry = $null } } function Get-CreatorControlCount { param([Parameter(Mandatory)][hashtable]$State) switch ([int]$State.Step) { 0 { if ($State.Scheme) { 4 } else { 1 } } 1 { @(Get-CreatorColorSlots).Count } 2 { 3 } 3 { 4 } default { 1 } } } function Get-CreatorCycled { # The entry $Delta places after $Current in $List, wrapping. A current # value the list lacks moves to the first entry (or the last, going left). param([Parameter(Mandatory)][object[]]$List, $Current, [Parameter(Mandatory)][int]$Delta) $i = -1 for ($n = 0; $n -lt $List.Count; $n++) { if ($List[$n] -ceq $Current) { $i = $n; break } } if ($i -lt 0) { if ($Delta -gt 0) { return $List[0] } else { return $List[$List.Count - 1] } } $List[((($i + $Delta) % $List.Count) + $List.Count) % $List.Count] } function Get-CreatorPromptColors { # The colours a prompt part can take: the foreground and the 16 terminal # colours, each once, as #rrggbb. param([Parameter(Mandatory)]$Scheme) $seen = New-Object System.Collections.Generic.List[string] foreach ($k in @(Get-CreatorColorSlots)) { if ($k -in 'background', 'cursorColor', 'selectionBackground') { continue } $hex = ConvertTo-NormalHex $Scheme.$k if ($hex -and -not $seen.Contains($hex)) { $seen.Add($hex) } } , $seen.ToArray() } function ConvertTo-CreatorKeyInput { # One key, as a [ConsoleKeyInfo] from the console or a key name from a # test ('Enter', 'Escape', 'Backspace', 'UpArrow', 'Shift+LeftArrow', or a # single character), or typed text. -> @{ Name; Shift; Chars }, where Name # is Up | Down | Left | Right | Enter | Back | Esc | PageUp | Chars | Other. param($Key, [AllowEmptyString()][AllowNull()][string]$Text) $out = @{ Name = 'Other'; Shift = $false; Chars = '' } if ($null -eq $Key -and $Text) { $out.Name = 'Chars' $out.Chars = -join @($Text.ToCharArray() | Where-Object { [int]$_ -ge 0x20 -and [int]$_ -ne 0x7F }) if (-not $out.Chars) { $out.Name = 'Other' } return $out } $name = $null; $char = $null if ($Key -is [System.ConsoleKeyInfo]) { $out.Shift = [bool]($Key.Modifiers -band [ConsoleModifiers]::Shift) $name = [string]$Key.Key $char = $Key.KeyChar } elseif ($null -ne $Key) { $s = [string]$Key if ($s.Length -eq 1) { $char = $s[0] } else { if ($s -like 'Shift+*') { $out.Shift = $true; $s = $s.Substring(6) } $name = $s } } switch ($name) { { $_ -in 'UpArrow', 'Up' } { $out.Name = 'Up'; return $out } { $_ -in 'DownArrow', 'Down' } { $out.Name = 'Down'; return $out } { $_ -in 'LeftArrow', 'Left' } { $out.Name = 'Left'; return $out } { $_ -in 'RightArrow', 'Right' } { $out.Name = 'Right'; return $out } 'Enter' { $out.Name = 'Enter'; return $out } { $_ -in 'Backspace', 'Back' } { $out.Name = 'Back'; return $out } { $_ -in 'Escape', 'Esc' } { $out.Name = 'Esc'; return $out } { $_ -in 'PageUp', 'PgUp', 'Prior' } { $out.Name = 'PageUp'; return $out } } if ($null -ne $char -and [int]$char -ge 0x20 -and [int]$char -ne 0x7F) { $out.Name = 'Chars' $out.Chars = [string]$char } $out } function Invoke-CreatorKey { <# .SYNOPSIS Apply one key to the creator state. -> @{ State; Action }. .DESCRIPTION Action is one of: none nothing for the preview to show (the page may still have changed, for example a typed character; redraw it) preview the look changed; write a preview next moved to the next step back moved to the previous step cancel Esc: put everything back save Enter on step 4 with a free, valid name error:<message> nothing changed but what is said; show the message Keys: Up/Down choose a control, Left/Right change it, Enter goes to the next step, PageUp to the previous one on every page, Esc cancels. Typed characters go to the path field on step 0, the name on step 4 and the hex entry on step 1. Backspace NEVER navigates from a text field: there it only deletes, and on an empty field does nothing. On steps 1-3, outside hex entry, Backspace goes back too. Going back to step 0 focuses the opacity control, not the path field, so a held Backspace stops there instead of eating the path. On step 1, Shift+Left/Right moves the hue, H opens hex entry (Enter applies it, Esc leaves it, not the creator) and P pulls the palette from the image again, keeping the colours set by hand. Step 0 starts from New-CreatorStartState, which has no scheme. Enter reads the typed path: it is unquoted, a leading ~ expanded and made absolute, and its type checked (Resolve-CreatorImagePath), then handed to -ReadImage. A readable image returns a NEW state from New-CreatorState, still on step 0. A different path typed later is read the same way; the new state keeps the settings chosen so far and the colours set by hand, and takes its other colours, and the prompt's, from the new image. Enter on the path already read moves on. -Key is a [ConsoleKeyInfo] or a key name; -Text is typed characters, for tests. -ReadImage is the reader (default Read-ImagePixels). #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)][hashtable]$State, $Key, [AllowEmptyString()][string]$Text, [scriptblock]$ReadImage = { param($p) @(Read-ImagePixels -Path $p) } ) Initialize-CreatorEngineState -State $State $in = ConvertTo-CreatorKeyInput -Key $Key -Text $Text $result = { param($s, $a) @{ State = $s; Action = $a } } if ($null -ne $State.HexEntry) { return (Invoke-CreatorHexKey -State $State -In $in) } if ($in.Name -eq 'Esc') { return (& $result $State 'cancel') } $count = Get-CreatorControlCount -State $State if ($in.Name -eq 'Up') { if ($State.Control -gt 0) { $State.Control-- } return (& $result $State 'none') } if ($in.Name -eq 'Down') { if ($State.Control -lt $count - 1) { $State.Control++ } return (& $result $State 'none') } if ($in.Name -eq 'PageUp') { if ([int]$State.Step -eq 0) { return (& $result $State 'none') } return (Step-CreatorBack -State $State) } switch ([int]$State.Step) { 0 { return (Invoke-CreatorImageKey -State $State -In $in -ReadImage $ReadImage) } 4 { return (Invoke-CreatorNameKey -State $State -In $in) } } if ($in.Name -eq 'Enter') { $State.Step++; $State.Control = 0 return (& $result $State 'next') } if ($in.Name -eq 'Back' -or $in.Name -eq 'PageUp') { return (Step-CreatorBack -State $State) } $delta = 0 if ($in.Name -eq 'Right') { $delta = 1 } if ($in.Name -eq 'Left') { $delta = -1 } switch ([int]$State.Step) { 1 { return (Invoke-CreatorColorKey -State $State -In $in -Delta $delta) } 2 { if ($delta -eq 0) { return (& $result $State 'none') } switch ([int]$State.Control) { 0 { $State.FontFace = Get-CreatorCycled -List $script:CreatorFontFaces -Current $State.FontFace -Delta $delta } 1 { $size = [Math]::Max($script:CreatorFontSizeMin, [Math]::Min($script:CreatorFontSizeMax, [int]$State.FontSize + $delta)) if ($size -eq [int]$State.FontSize) { return (& $result $State 'none') } $State.FontSize = $size } 2 { $State.CursorShape = Get-CreatorCycled -List $script:CreatorCursorShapes -Current $State.CursorShape -Delta $delta } } return (& $result $State 'preview') } 3 { # The prompt shows only in the sample session, so nothing here # changes the terminal: the loop just redraws the page. if ($delta -eq 0) { return (& $result $State 'none') } switch ([int]$State.Control) { 0 { $State.PromptShape = Get-CreatorCycled -List @(Get-StylePromptShapes) -Current $State.PromptShape -Delta $delta } 1 { $State.Symbol = Get-CreatorCycled -List @(Get-StylePromptSymbols) -Current $State.Symbol -Delta $delta } 2 { $State.PromptPathColor = Get-CreatorCycled -List (Get-CreatorPromptColors -Scheme $State.Scheme) -Current (ConvertTo-NormalHex $State.PromptPathColor) -Delta $delta } 3 { $State.PromptSymbolColor = Get-CreatorCycled -List (Get-CreatorPromptColors -Scheme $State.Scheme) -Current (ConvertTo-NormalHex $State.PromptSymbolColor) -Delta $delta } } return (& $result $State 'none') } } & $result $State 'none' } function Step-CreatorBack { # One step back, landing on a control that is not a text field: step 0's # opacity (it has a scheme by then), the first control elsewhere. param([Parameter(Mandatory)][hashtable]$State) $State.Step = [int]$State.Step - 1 $State.Control = 0 if ($State.Step -eq 0 -and $State.Scheme) { $State.Control = 1 } @{ State = $State; Action = 'back' } } function Invoke-CreatorImageKey { # Step 0: the path field (control 0), then opacity, fit and position. param([hashtable]$State, [hashtable]$In, [scriptblock]$ReadImage) $result = { param($s, $a) @{ State = $s; Action = $a } } switch ($In.Name) { 'Chars' { $State.Control = 0 $State.PathText = [string]$State.PathText + $In.Chars return (& $result $State 'none') } 'Back' { # Deletes in the path field; never goes back -- there is no step # before this one. if ($State.Control -eq 0 -and $State.PathText.Length -gt 0) { $State.PathText = $State.PathText.Substring(0, $State.PathText.Length - 1) } return (& $result $State 'none') } 'Enter' { return (Read-CreatorImage -State $State -ReadImage $ReadImage) } } $delta = 0 if ($In.Name -eq 'Right') { $delta = 1 } if ($In.Name -eq 'Left') { $delta = -1 } if ($delta -eq 0 -or -not $State.Scheme) { return (& $result $State 'none') } switch ([int]$State.Control) { 1 { $o = [Math]::Round([Math]::Max(0.0, [Math]::Min(1.0, [double]$State.ImageOpacity + 0.05 * $delta)), 2) if ($o -eq [double]$State.ImageOpacity) { return (& $result $State 'none') } $State.ImageOpacity = $o } 2 { $State.StretchMode = Get-CreatorCycled -List $script:CreatorStretchModes -Current $State.StretchMode -Delta $delta } 3 { $State.Alignment = Get-CreatorCycled -List $script:CreatorAlignments -Current $State.Alignment -Delta $delta } default { return (& $result $State 'none') } } & $result $State 'preview' } function Resolve-CreatorImagePath { <# .SYNOPSIS What Enter on step 0 does with the typed path, before anything is read: @{ Full; Error; Read }. .DESCRIPTION The path is unquoted (ConvertFrom-PastedPath), a leading ~ (alone, or before / or \) is $HOME, and the rest is made absolute against the current location. Error is the message for a path that cannot be used: empty, not a path, or a type Get-BackgroundImageSaveExtension (the rule Save-CreatedStyle applies) refuses -- refused here, so an image the save would refuse never gets the user to the last page. Read is true when the reader has to run: not the image already read. Read-CreatorImage and the loop's "Reading..." line both ask here. #> param([Parameter(Mandatory)][hashtable]$State) $out = @{ Full = $null; Error = $null; Read = $false } $typed = ConvertFrom-PastedPath ([string]$State.PathText) if (-not $typed) { $out.Error = 'Type or paste the path to a PNG, JPEG or GIF image, then press Enter.'; return $out } $expanded = $typed if ($typed -eq '~') { $expanded = $HOME } elseif ($typed.StartsWith('~/') -or $typed.StartsWith('~\')) { $expanded = [System.IO.Path]::Combine($HOME, $typed.Substring(2)) } try { $base = (Get-Location -PSProvider FileSystem).ProviderPath $out.Full = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($base, $expanded)) } catch { $out.Error = "'$typed' is not a path this computer can use." return $out } if (-not (Get-BackgroundImageSaveExtension -Path $out.Full)) { $out.Error = ("'{0}' is not a file type tstyles create takes. Use a {1} file." -f [System.IO.Path]::GetFileName($out.Full), (Get-BackgroundImageTypesText)) return $out } $out.Read = -not ($State.Scheme -and ([string]$State.ImagePath) -ceq $out.Full) $out } function Get-CreatorImageToRead { # The absolute path this key will hand the image reader, or $null: Enter # on step 0 (outside hex entry) with a usable path not already read. The # loop asks first, to say "Reading ..." before a large image holds it. param([Parameter(Mandatory)][hashtable]$State, $Key) if ([int]$State.Step -ne 0) { return $null } if ($State.ContainsKey('HexEntry') -and $null -ne $State.HexEntry) { return $null } if ((ConvertTo-CreatorKeyInput -Key $Key).Name -ne 'Enter') { return $null } $r = Resolve-CreatorImagePath -State $State if ($r.Error -or -not $r.Read) { return $null } $r.Full } function Read-CreatorImage { # Enter on step 0. See Invoke-CreatorKey for what it does. param([hashtable]$State, [scriptblock]$ReadImage) $result = { param($s, $a) @{ State = $s; Action = $a } } $r = Resolve-CreatorImagePath -State $State if ($r.Error) { return (& $result $State ('error:' + $r.Error)) } $full = $r.Full if (-not $r.Read) { $State.Step = 1; $State.Control = 0 return (& $result $State 'next') } try { $px = [int[]]@(& $ReadImage $full) $new = New-CreatorState -Pixels $px -ImagePath $full } catch { return (& $result $State ('error:' + $_.Exception.Message)) } if ($State.Scheme) { foreach ($k in 'ImageOpacity', 'StretchMode', 'Alignment', 'FontFace', 'FontSize', 'CursorShape', 'PromptShape', 'Symbol', 'Name') { $new[$k] = $State[$k] } foreach ($slot in @($State.Edited)) { Set-CreatorColor -State $new -Slot $slot -Hex $State.Scheme.$slot } } $new.PathText = $State.PathText $new.Control = $State.Control $new.HexEntry = $null & $result $new 'preview' } function Invoke-CreatorColorKey { # Step 1: one colour slot per control. param([hashtable]$State, [hashtable]$In, [int]$Delta) $result = { param($s, $a) @{ State = $s; Action = $a } } $slot = @(Get-CreatorColorSlots)[[int]$State.Control] if ($In.Name -eq 'Chars') { switch -CaseSensitive ($In.Chars) { { $_ -in 'h', 'H' } { $State.HexEntry = ''; return (& $result $State 'none') } { $_ -in 'p', 'P' } { Update-CreatorPalette -State $State; return (& $result $State 'preview') } } return (& $result $State 'none') } if ($Delta -eq 0) { return (& $result $State 'none') } $hex = ConvertTo-NormalHex $State.Scheme.$slot if (-not $hex) { return (& $result $State "error:$slot is not a colour; press H to type one.") } $hsl = ConvertTo-PaletteHsl $hex if ($In.Shift) { $new = ConvertFrom-PaletteHsl -H ($hsl[0] + 10.0 * $Delta) -S $hsl[1] -L $hsl[2] } else { $new = ConvertFrom-PaletteHsl -H $hsl[0] -S $hsl[1] -L ($hsl[2] + 0.03 * $Delta) } if ($new -eq $hex) { return (& $result $State 'none') } Set-CreatorColor -State $State -Slot $slot -Hex $new & $result $State 'preview' } function Invoke-CreatorHexKey { # Step 1 with hex entry open: a text field for the selected slot. param([hashtable]$State, [hashtable]$In) $result = { param($s, $a) @{ State = $s; Action = $a } } switch ($In.Name) { 'Esc' { $State.HexEntry = $null; return (& $result $State 'none') } 'Back' { if ($State.HexEntry.Length -gt 0) { $State.HexEntry = $State.HexEntry.Substring(0, $State.HexEntry.Length - 1) } return (& $result $State 'none') } 'Chars' { $t = $State.HexEntry + $In.Chars if ($t.Length -gt 9) { $t = $t.Substring(0, 9) } $State.HexEntry = $t return (& $result $State 'none') } 'Enter' { $slot = @(Get-CreatorColorSlots)[[int]$State.Control] $typed = [string]$State.HexEntry if (-not $typed.Trim()) { return (& $result $State 'error:Type a colour as #rrggbb.') } if (-not (ConvertTo-NormalHex $typed)) { return (& $result $State "error:'$typed' is not a colour. Type a colour as #rrggbb.") } try { Set-CreatorColor -State $State -Slot $slot -Hex $State.HexEntry } catch { return (& $result $State ('error:' + $_.Exception.Message)) } $State.HexEntry = $null return (& $result $State 'preview') } } & $result $State 'none' } function Invoke-CreatorNameKey { # Step 4: the name field. param([hashtable]$State, [hashtable]$In) $result = { param($s, $a) @{ State = $s; Action = $a } } $name = [string]$State.Name switch ($In.Name) { 'Chars' { $State.Name = $name + $In.Chars return (& $result $State 'none') } 'Back' { # Deletes only; on an empty name it does nothing (PageUp goes back). if ($name.Length -gt 0) { $State.Name = $name.Substring(0, $name.Length - 1) } return (& $result $State 'none') } 'Enter' { if (-not $name) { return (& $result $State 'error:Type a name for the style.') } if ($script:TStylesSubcommands -contains $name.ToLowerInvariant()) { return (& $result $State "error:'$name' is a tstyles command, so it cannot be a style name.") } if (-not (Test-StyleNameValid -Name $name)) { return (& $result $State ("error:'$name' is not a usable name. " + (Get-StyleNameRuleText))) } if (Test-CreatedStyleNameTaken -Name $name) { return (& $result $State "error:'$name' is taken: a style with that name already exists. Choose another.") } return (& $result $State 'save') } } & $result $State 'none' } # ---- the page ---------------------------------------------------------------- function Get-CreatorFg { # A 24-bit foreground escape for a #rrggbb, or '' when it is not a colour. param($Hex) $h = ConvertTo-NormalHex $Hex if (-not $h) { return '' } "$([char]27)[38;2;{0};{1};{2}m" -f [Convert]::ToInt32($h.Substring(1, 2), 16), [Convert]::ToInt32($h.Substring(3, 2), 16), [Convert]::ToInt32($h.Substring(5, 2), 16) } function Get-CreatorSampleLines { # The fixed sample session: made up, never the user's folder or branch. param([Parameter(Mandatory)][hashtable]$State) $e = [char]27 $x = "$e[0m" $pathC = ''; $symC = ''; $curC = '' if ($State.Scheme) { $pathC = Get-CreatorFg $State.PromptPathColor $symC = Get-CreatorFg $State.PromptSymbolColor $curC = Get-CreatorFg $State.Scheme.cursorColor } $pathEnd = ''; if ($pathC) { $pathEnd = $x } $symEnd = ''; if ($symC) { $symEnd = $x } $shape = [string]$State.PromptShape if (-not $shape) { $shape = 'minimal' } $symbol = [string]$State.Symbol if (-not $symbol) { $symbol = '$' } $where = 'projects' if ($shape -eq 'path' -or $shape -eq 'twoline') { $where = '~/code/projects' } $head = $pathC + $where + $pathEnd if ($shape -eq 'git') { $head += ' (main)' } $sym = $symC + $symbol + $symEnd # Each prompt is a list of lines; the command goes after the last one. $prompt = { param([string]$Command) if ($shape -eq 'twoline') { $head; ($sym + ' ' + $Command) } else { ($head + ' ' + $sym + ' ' + $Command) } } $cursor = switch ([string]$State.CursorShape) { 'vintage' { [string][char]0x2584 } 'underscore' { '_' } 'doubleUnderscore' { '=' } 'filledBox' { [string][char]0x2588 } 'emptyBox' { [string][char]0x25AF } default { [string][char]0x2502 } } $curEnd = ''; if ($curC) { $curEnd = $x } $lines = New-Object System.Collections.Generic.List[string] foreach ($l in @(& $prompt 'git status')) { $lines.Add($l) } $lines.Add('On branch main') $lines.Add('Changes not staged for commit:') $lines.Add(" $e[31mmodified: readme.md$x") $lines.Add(" $e[31mdeleted: old.txt$x") $lines.Add('') foreach ($l in @(& $prompt 'ls')) { $lines.Add($l) } $lines.Add("$e[34msrc$x $e[34mdocs$x build.ps1") foreach ($l in @(& $prompt ($curC + $cursor + $curEnd))) { $lines.Add($l) } # Unrolled on purpose: the caller enumerates it. $lines.ToArray() } function Get-CreatorPageLines { <# .SYNOPSIS The page for the state's step, as lines: the controls, any warnings, then the fixed sample session. .DESCRIPTION Pure: reads only the state, so the sample never shows the user's folder or branch. Colours are escape sequences; the sample's command output uses the terminal's own palette (the scheme being previewed), the prompt the prompt's chosen colours. #> [CmdletBinding()] [OutputType([string[]])] param([Parameter(Mandatory)][hashtable]$State) $e = [char]27 $x = "$e[0m" $inv = [System.Globalization.CultureInfo]::InvariantCulture $step = [int]$State.Step $control = 0; if ($State.ContainsKey('Control') -and $null -ne $State.Control) { $control = [int]$State.Control } $hexEntry = $null; if ($State.ContainsKey('HexEntry')) { $hexEntry = $State.HexEntry } $pathText = [string]$State.PathText if (-not $State.ContainsKey('PathText') -and $State.ImagePath) { $pathText = [string]$State.ImagePath } $caret = "$e[7m $x" # A text field longer than its room shows its end, after '...': the page # stays one line per row in a 120-column window (the row's label takes 19). $field = { param([string]$Text, [int]$Room = 96) if ($Text.Length -le $Room) { return $Text } '...' + $Text.Substring($Text.Length - ($Room - 3)) } $lines = New-Object System.Collections.Generic.List[string] $lines.Add((' tstyles create Step {0} of 5: {1}' -f ($step + 1), $script:CreatorStepTitles[$step])) $lines.Add('') $row = { param([int]$Index, [string]$Label, [string]$Value) $mark = ' '; if ($Index -eq $control) { $mark = '>' } (' {0} {1,-14} {2}' -f $mark, $Label, $Value) } $swatch = { param($Value) $h = ConvertTo-NormalHex $Value if (-not $h) { return ([string]$Value) } $bg = "$e[48;2;{0};{1};{2}m" -f [Convert]::ToInt32($h.Substring(1, 2), 16), [Convert]::ToInt32($h.Substring(3, 2), 16), [Convert]::ToInt32($h.Substring(5, 2), 16) ('{0} {1} {2}' -f $h, $bg, $x) } $hint = '' $showWarnings = $false switch ($step) { 0 { $pathValue = & $field $pathText if ($control -eq 0) { $pathValue += $caret } $lines.Add((& $row 0 'Image' $pathValue)) if ($State.Scheme) { $lines.Add((& $row 1 'Opacity' ([double]$State.ImageOpacity).ToString('0.00', $inv))) $lines.Add((& $row 2 'Fit' ([string]$State.StretchMode))) $lines.Add((& $row 3 'Position' ([string]$State.Alignment))) $hint = 'Up/Down choose, Left/Right change, Enter next, Esc cancel. Type another path and Enter to read it.' } else { $hint = 'Type or paste the path to a PNG, JPEG or GIF image, then press Enter. Esc cancels.' } } 1 { $slots = @(Get-CreatorColorSlots) $half = [int][Math]::Ceiling($slots.Count / 2.0) for ($i = 0; $i -lt $half; $i++) { $left = & $row $i $slots[$i] (& $swatch $State.Scheme.($slots[$i])) $j = $i + $half if ($j -lt $slots.Count) { $right = & $row $j $slots[$j] (& $swatch $State.Scheme.($slots[$j])) $left = $left + ' ' + $right } $lines.Add($left) } if ($null -ne $hexEntry) { # On the hint's line, so hex entry does not make the page taller. $hint = ('Hex for {0}: {1}{2} Type #rrggbb. Enter applies, Esc leaves hex entry.' -f $slots[$control], $hexEntry, $caret) } else { # One line of at most 100: PgUp and Backspace going back are on # the next pages' hints; P keeps the colours set by hand. $hint = 'Up/Down pick, Left/Right lightness, Shift+Left/Right hue, H hex, P re-pull, Enter next, Esc cancel.' } $showWarnings = $true } 2 { $lines.Add((& $row 0 'Font' ([string]$State.FontFace))) $lines.Add((& $row 1 'Font size' ([string]$State.FontSize))) $lines.Add((& $row 2 'Cursor' ([string]$State.CursorShape))) $hint = 'Up/Down choose, Left/Right change, Enter next, PgUp or Backspace previous step, Esc cancel.' } 3 { $lines.Add((& $row 0 'Shape' ([string]$State.PromptShape))) $lines.Add((& $row 1 'Symbol' ([string]$State.Symbol))) $lines.Add((& $row 2 'Path colour' (& $swatch $State.PromptPathColor))) $lines.Add((& $row 3 'Symbol colour' (& $swatch $State.PromptSymbolColor))) $hint = 'Up/Down choose, Left/Right change, Enter next, PgUp or Backspace previous step, Esc cancel.' } 4 { $lines.Add((& $row 0 'Name' ((& $field ([string]$State.Name)) + $caret))) $hint = 'Type a name. Backspace deletes, Enter saves and applies it, PgUp previous step, Esc cancels.' $showWarnings = $true } } $lines.Add('') $lines.Add(" $hint") if ($showWarnings -and $State.Scheme) { # One line however many there are, so the page fits a 120x30 window. $w = Get-CreatorWarningLine -State $State if ($w) { $lines.Add(" $e[33m! $w$x") } } $lines.Add('') foreach ($l in @(Get-CreatorSampleLines -State $State)) { $lines.Add($l) } , $lines.ToArray() } |