src/Tui.ps1
|
# Tui.ps1 - the interactive console UI (Simple + Detail modes). # Hand-rolled with [Console]::ReadKey + truecolor ANSI so there are zero deps. $e = [char]27 # ESC function Write-Fg { param([string]$Hex, [string]$Text) $r = [convert]::ToInt32($Hex.Substring(1,2),16) $g = [convert]::ToInt32($Hex.Substring(3,2),16) $b = [convert]::ToInt32($Hex.Substring(5,2),16) "$e[38;2;$r;$g;${b}m$Text$e[0m" } # --- shared UI primitives (refined-flat style) -------------------------------- # A dim horizontal rule under section titles. function Write-PPRule { param([int] $Width = 54) Write-Host (' ' + ([string][char]0x2500 * $Width)) -ForegroundColor DarkGray } # A dim footer of key hints, separated by middots. function Write-PPFooter { param([string[]] $Hints) Write-Host ''; Write-Host (' ' + ($Hints -join ' · ')) -ForegroundColor DarkGray } # Longest .Length among strings, for programmatic column sizing. function Get-PPMaxLen { param([string[]] $Strings) (@($Strings | ForEach-Object { ([string]$_).Length }) + 0 | Measure-Object -Maximum).Maximum } # One selectable row. The selected row gets an inverse-video pill; the leading # marker area is a fixed 3 cells (" ❯ " when selected, " " when not) so the # text starts at the same column in both states. function Write-PPRow { param([bool] $Selected, [string] $Text, [int] $Width) $pad = ([string]$Text).PadRight($Width) if ($Selected) { Write-Host ' ' -NoNewline Write-Host (" ❯ $pad ") -ForegroundColor Black -BackgroundColor Gray } else { Write-Host (" $pad ") -ForegroundColor DarkGray } } # Merge a working composition (hashtable) with optional slot overrides and # return it as a PSCustomObject ready for Resolve-PoshPaletteTheme. function ConvertTo-PPComposition { param([hashtable] $Comp, [hashtable] $Override = @{}) $h = @{} foreach ($k in $Comp.Keys) { $h[$k] = $Comp[$k] } foreach ($k in $Override.Keys) { $h[$k] = $Override[$k] } [pscustomobject]$h } # "#rrggbb" -> "r;g;b" for truecolor ANSI. function ConvertTo-PPRgb { param([string] $h) '{0};{1};{2}' -f [convert]::ToInt32($h.Substring(1,2),16), [convert]::ToInt32($h.Substring(3,2),16), [convert]::ToInt32($h.Substring(5,2),16) } # Visible width of a string, ignoring SGR color escapes. function Get-PPVisibleLength { param([string] $s) ($s -replace "$e\[[0-9;]*m", '').Length } # Render the actual prompt for a resolved theme. If oh-my-posh is available we ask # it to print the real prompt for this exact config (generated 'auto' prompt, or a # referenced theme under POSH_THEMES_PATH); otherwise we fall back to a simple # scheme-colored prompt. Cached per prompt config so scrolling stays responsive. $script:PPPromptCache = @{} function Get-PoshPalettePromptAnsi { param($Theme) $key = ($Theme.prompt | ConvertTo-Json -Depth 32 -Compress) if ($script:PPPromptCache.ContainsKey($key)) { return $script:PPPromptCache[$key] } $ansi = $null $omp = Get-Command oh-my-posh -ErrorAction SilentlyContinue if ($omp) { $cfg = $null if ($Theme.prompt.generated) { try { $cfg = Save-PoshPalettePrompt -Config $Theme.prompt.config -Name 'pp-preview' } catch { } } elseif ($Theme.prompt.ohMyPoshTheme -and $env:POSH_THEMES_PATH) { $maybe = Join-Path $env:POSH_THEMES_PATH ('{0}.omp.json' -f $Theme.prompt.ohMyPoshTheme) if (Test-Path $maybe) { $cfg = $maybe } } if ($cfg) { try { $out = & $omp.Source print primary --config $cfg --shell pwsh --pwd 'C:\Users\you\posh-palette' 2>$null $ansi = ($out -join "`n") } catch { } } } if ([string]::IsNullOrWhiteSpace($ansi)) { $sc = $Theme.terminal.scheme $name = if ($Theme.prompt.ohMyPoshTheme) { $Theme.prompt.ohMyPoshTheme } else { 'posh-palette' } $ansi = "$e[38;2;$(ConvertTo-PPRgb $sc.blue)m$name $e[38;2;$(ConvertTo-PPRgb $sc.purple)m❯$e[0m" } $ansi = ($ansi -replace "`r", '' -replace "`n", '').TrimEnd() $script:PPPromptCache[$key] = $ansi $ansi } # A mini terminal session drawn from the theme's own hex values on a filled block # of the theme's BACKGROUND color, so the whole thing recolors as you scroll. It # renders the real oh-my-posh prompt plus a few representative commands + output. function Show-PoshPalettePreview { param($Theme, [int] $Left = 42, [int] $Top = 4) $W = 46 $bw = try { [Console]::BufferWidth } catch { 120 } if ($bw -lt ($Left + $W + 1)) { return } # not enough room for a side panel; skip cleanly $sc = $Theme.terminal.scheme $pr = $Theme.psReadLine $ps = $Theme.psStyle $rgb = { param($h) ConvertTo-PPRgb $h } $bg = & $rgb $sc.background # Plain colored line from flat hex,text,hex,text... pairs, padded on the bg. $row = { param([object[]] $parts) $s = "$e[48;2;${bg}m"; $len = 0 for ($j = 0; $j -lt $parts.Count; $j += 2) { $hex = $parts[$j]; if (-not $hex) { $hex = $sc.foreground } $s += "$e[38;2;$(& $rgb $hex)m$($parts[$j + 1])" $len += ('' + $parts[$j + 1]).Length } if ($len -lt $W) { $s += (' ' * ($W - $len)) } $s + "$e[0m" } # The real prompt followed by a typed command, on the bg block. $promptAnsi = Get-PoshPalettePromptAnsi $Theme $promptBg = $promptAnsi -replace [regex]::Escape("$e[0m"), "$e[0m$e[48;2;${bg}m" $promptVis = Get-PPVisibleLength $promptAnsi $prow = { param($cmdHex, $cmdText) if (-not $cmdHex) { $cmdHex = $sc.foreground } $vis = 1 + $promptVis + 1 + ('' + $cmdText).Length $s = "$e[48;2;${bg}m " + $promptBg + "$e[38;2;$(& $rgb $cmdHex)m $cmdText" if ($vis -lt $W) { $s += (' ' * ($W - $vis)) } $s + "$e[0m" } $lines = @( (& $prow $pr.Command 'Get-ChildItem') (& $row @($null, '')) (& $row @($ps.TableHeader, 'Mode LastWriteTime Length Name')) (& $row @($pr.Comment, '---- ------------- ------ ----')) (& $row @($null, 'd---- 6/20/2026 9:39 AM ', $ps.Directory, 'src')) (& $row @($null, 'd---- 6/20/2026 9:39 AM ', $ps.Directory, 'docs')) (& $row @($null, '-a--- 6/18/2026 1:12 PM ', $pr.Number, '8.9k', $null, ' README.md')) (& $row @($null, '')) (& $prow $pr.Command 'git pull') (& $row @($null, 'Updating ', $pr.Number, '1a2b3c4', $null, '..', $pr.Number, '5d6e7f8')) (& $row @($sc.green, ' 3 files changed, ', $sc.green, '42 insertions(+)')) (& $row @($null, '')) (& $prow $pr.Command 'npm test') (& $row @($sc.green, ' ✓ ', $null, '42 passing')) (& $row @($sc.red, ' ✗ ', $null, '1 failing')) (& $row @($null, '')) (& $prow $sc.foreground '█') ) [Console]::CursorVisible = $false for ($i = 0; $i -lt $lines.Count; $i++) { try { [Console]::SetCursorPosition($Left, $Top + $i); [Console]::Write($lines[$i]) } catch { } } [Console]::Write("$e[0m") } # Generic scrollable picker. Items need a .Name; returns the chosen item or $null. # A navigable "Back" row sits below the items (Esc is still the shortcut). When # -CustomPrompt is given, a "Type a name..." row lets you enter a value directly # (returned as a synthetic item), so you can use any oh-my-posh theme / font name. function Show-PoshPaletteList { param([string] $Title, [array] $Items, [scriptblock] $PreviewFor, [string] $CustomPrompt) $hasCustom = [bool]$CustomPrompt $customIdx = if ($hasCustom) { $Items.Count } else { -1 } $backIdx = $Items.Count + $(if ($hasCustom) { 1 } else { 0 }) $total = $backIdx + 1 $extra = if ($hasCustom) { @('⌨ Type a name...', '← Back') } else { @('← Back') } $width = [Math]::Max((Get-PPMaxLen (@($Items | ForEach-Object { $_.Name }) + $extra)), 16) $idx = 0 [Console]::CursorVisible = $false try { while ($true) { Clear-Host Write-Host "" Write-Host " $Title" -ForegroundColor White Write-PPRule Write-Host "" for ($i = 0; $i -lt $Items.Count; $i++) { Write-PPRow ($i -eq $idx) $Items[$i].Name $width } if ($hasCustom) { Write-PPRow ($idx -eq $customIdx) '⌨ Type a name...' $width } Write-PPRow ($idx -eq $backIdx) '← Back' $width Write-PPFooter @('↑/↓ move', 'Enter select', 'Esc back') if ($PreviewFor -and $idx -lt $Items.Count) { & $PreviewFor $Items[$idx] } $key = [Console]::ReadKey($true) switch ($key.Key) { 'UpArrow' { $idx = ($idx - 1 + $total) % $total } 'DownArrow' { $idx = ($idx + 1) % $total } 'Escape' { return $null } 'Enter' { if ($idx -eq $backIdx) { return $null } elseif ($hasCustom -and $idx -eq $customIdx) { [Console]::CursorVisible = $true Write-Host '' Write-Host " $CustomPrompt" -ForegroundColor Cyan $val = Read-Host ' name' if (-not [string]::IsNullOrWhiteSpace($val)) { return [pscustomobject]@{ Id = $val.Trim(); Name = $val.Trim(); Custom = $true } } } else { return $Items[$idx] } } } } } finally { [Console]::CursorVisible = $true } } # Font info panel (fonts can't be rendered live - the terminal uses one font for # the whole window - so we show the name + how to install/apply it). function Show-PoshPaletteFontInfo { param($Font, [int] $Left = 42, [int] $Top = 4) $bw = try { [Console]::BufferWidth } catch { 120 } if ($bw -lt ($Left + 30)) { return } $dim = "$e[38;5;245m"; $wht = "$e[97m"; $rst = "$e[0m" $name = if ($Font.Custom) { $Font.Name } else { $Font.name } $face = if ($Font.face) { $Font.face } else { $name } $id = if ($Font.Custom) { '<face name>' } else { $Font.id } $lines = @( "${wht}Font${rst}" '' "${wht}$name${rst}" "${dim}face: $face${rst}" '' "${dim}A font can't be shown here - the${rst}" "${dim}terminal renders one font for the${rst}" "${dim}whole window.${rst}" '' "${dim}Install a bundled one:${rst}" "${dim} Install-PoshPaletteFont $id${rst}" "${dim}then pick it as your terminal font.${rst}" ) [Console]::CursorVisible = $false for ($i = 0; $i -lt $lines.Count; $i++) { try { [Console]::SetCursorPosition($Left, $Top + $i); [Console]::Write($lines[$i]) } catch { } } [Console]::Write($rst) } # --- Simple mode: pick a full preset ------------------------------------------ # Shown after a theme is applied: confirms what happened and what to do next. # Returns 'quit' if the user chose to quit, otherwise $null (back to menu). function Show-PoshPaletteApplied { param($Theme) Write-Host "" Write-Host " ✓ Applied '$($Theme.name)'" -ForegroundColor Green Write-Host "" Write-Host " Next steps" -ForegroundColor Cyan Write-Host " • Terminal colors update instantly in this window." -ForegroundColor Gray Write-Host " • Open a NEW tab or window to load the prompt + input colors." -ForegroundColor Gray Write-Host " • Tweak one layer later, e.g. Set-PoshPalettePrompt <name>" -ForegroundColor Gray Write-Host "" Write-Host " [Enter] back to menu [Q] quit" -ForegroundColor DarkGray while ($true) { $k = [Console]::ReadKey($true) if ($k.Key -eq 'Enter' -or $k.Key -eq 'Escape') { return $null } if ([string]$k.KeyChar -in 'q', 'Q') { return 'quit' } } } function Invoke-PoshPaletteSimpleMode { $themes = Get-PoshPaletteThemes $chosen = Show-PoshPaletteList -Title 'Simple mode - pick a theme' -Items $themes -PreviewFor { param($t) Show-PoshPalettePreview -Theme (Resolve-PoshPaletteTheme $t.Data) } if ($chosen) { Clear-Host $t = Resolve-PoshPaletteTheme $chosen.Data Set-PoshPaletteTheme -Theme $t return (Show-PoshPaletteApplied $t) } } # Adjust a numeric value with the arrow keys. Returns the new value, or $null on Esc. function Invoke-PoshPaletteAdjust { param([string] $Label, [int] $Value, [int] $Min, [int] $Max, [int] $Step, [string] $Suffix = '') [Console]::CursorVisible = $false try { while ($true) { Clear-Host Write-Host "`n $Label" -ForegroundColor Cyan Write-Host " </> adjust Enter confirm Esc cancel`n" -ForegroundColor DarkGray Write-Host (" " + $Value + $Suffix) -ForegroundColor White $key = [Console]::ReadKey($true) switch ($key.Key) { 'LeftArrow' { $Value = [Math]::Max($Min, $Value - $Step) } 'RightArrow' { $Value = [Math]::Min($Max, $Value + $Step) } 'Enter' { return $Value } 'Escape' { return $null } } } } finally { [Console]::CursorVisible = $true } } # --- Detail mode: compose each layer independently ---------------------------- # Build a live preview callback for a list picker that swaps one slot of the # working composition. We use a plain (non-closure) scriptblock bound to the # module session state plus $script: capture vars, because .GetNewClosure() # rebinds the block to a fresh dynamic module that can't see module functions # like ConvertTo-PPComposition / Resolve-PoshPaletteTheme. $script:PPPreviewComp = $null $script:PPPreviewSlot = $null function New-PoshPalettePreviewFor { param([hashtable] $Comp, [string] $Slot) $script:PPPreviewComp = $Comp $script:PPPreviewSlot = $Slot { param($it) Show-PoshPalettePreview -Theme (Resolve-PoshPaletteTheme (ConvertTo-PPComposition $script:PPPreviewComp @{ $script:PPPreviewSlot = $it.Id })) } } # Preview callback for the font picker: show the font info panel. function New-PoshPaletteFontPreviewFor { { param($it) Show-PoshPaletteFontInfo -Font $it } } function Invoke-PoshPaletteDetailMode { $presets = Get-PoshPaletteThemes if (-not $presets) { return } $base = $presets[0].Data # Working composition as a mutable hashtable, seeded from the first preset. $comp = @{ name = 'Custom' scheme = $base.scheme palette = $base.palette prompt = $base.prompt font = $base.font fontSize = [int]($base.fontSize ?? 11) opacity = [int]($base.opacity ?? 100) acrylic = [bool]($base.acrylic ?? $false) } # Menu rows: 7 editable fields + Apply + Back. Arrows OR number/letter keys. $rows = @('scheme', 'palette', 'prompt', 'font', 'opacity', 'acrylic', 'fontsize', 'apply', 'back') $idx = 0 $invoke = { param([string] $id) switch ($id) { 'scheme' { $p = Show-PoshPaletteList -Title 'Color scheme' -Items (Get-PoshPaletteCatalog schemes) -PreviewFor (New-PoshPalettePreviewFor $comp 'scheme') if ($p) { $comp.scheme = $p.Id } } 'palette' { $p = Show-PoshPaletteList -Title 'Shell colors (PSReadLine + output)' -Items (Get-PoshPaletteCatalog palettes) -PreviewFor (New-PoshPalettePreviewFor $comp 'palette') if ($p) { $comp.palette = $p.Id } } 'prompt' { $p = Show-PoshPaletteList -Title 'Prompt (oh-my-posh)' -Items (Get-PoshPaletteCatalog prompts) ` -PreviewFor (New-PoshPalettePreviewFor $comp 'prompt') ` -CustomPrompt 'Type an oh-my-posh theme name (e.g. atomic, jandedobbeleer):' if ($p) { $comp.prompt = $p.Id } } 'font' { # A font can't be rendered live; show its name + install hint, and # allow typing any installed font face directly. $p = Show-PoshPaletteList -Title 'Font' -Items (Get-PoshPaletteFonts) ` -PreviewFor (New-PoshPaletteFontPreviewFor) ` -CustomPrompt 'Type an installed font face (e.g. Cascadia Code NF):' if ($p) { $comp.font = $p.Id } } 'opacity' { $v = Invoke-PoshPaletteAdjust 'Opacity' ([int]$comp.opacity) 30 100 5 '%' if ($null -ne $v) { $comp.opacity = $v } } 'acrylic' { $comp.acrylic = -not [bool]$comp.acrylic } 'fontsize' { $v = Invoke-PoshPaletteAdjust 'Font size' ([int]$comp.fontSize) 8 24 1 if ($null -ne $v) { $comp.fontSize = $v } } 'apply' { Clear-Host $t = Resolve-PoshPaletteTheme (ConvertTo-PPComposition $comp) Set-PoshPaletteTheme -Theme $t return (Show-PoshPaletteApplied $t) # 'quit' or $null } } return '' } [Console]::CursorVisible = $false try { while ($true) { Clear-Host Write-Host "" Write-Host " Detail mode - compose your look" -ForegroundColor White Write-PPRule Write-Host "" $tr = { param($s) $v = [string]$s; if ($v.Length -gt 16) { $v.Substring(0, 15) + '…' } else { $v } } $labels = @( "[1] Scheme : $(& $tr $comp.scheme)" "[2] Shell colors : $(& $tr $comp.palette)" "[3] Prompt : $(& $tr $comp.prompt)" "[4] Font : $(& $tr $comp.font)" "[5] Opacity : $($comp.opacity)%" "[6] Acrylic : $(if ($comp.acrylic) { 'on' } else { 'off' })" "[7] Font size : $($comp.fontSize)" "[A] Apply" "[Esc] ← Back" ) $lw = Get-PPMaxLen $labels for ($i = 0; $i -lt $labels.Count; $i++) { Write-PPRow ($i -eq $idx) $labels[$i] $lw } Write-PPFooter @('↑/↓ move', 'Enter edit', 'A apply', 'Esc back') # live preview as the right column, top-aligned with the field rows Show-PoshPalettePreview -Theme (Resolve-PoshPaletteTheme (ConvertTo-PPComposition $comp)) -Left ($lw + 9) -Top 4 $key = [Console]::ReadKey($true) if ($key.Key -eq 'Escape') { return } $target = $null switch ($key.Key) { 'UpArrow' { $idx = ($idx - 1 + $rows.Count) % $rows.Count } 'DownArrow' { $idx = ($idx + 1) % $rows.Count } 'Enter' { $target = $rows[$idx] } } switch ($key.KeyChar) { '1' { $target = 'scheme'; $idx = 0 } '2' { $target = 'palette'; $idx = 1 } '3' { $target = 'prompt'; $idx = 2 } '4' { $target = 'font'; $idx = 3 } '5' { $target = 'opacity'; $idx = 4 } '6' { $target = 'acrylic'; $idx = 5 } '7' { $target = 'fontsize'; $idx = 6 } { $_ -in 'a', 'A' } { $target = 'apply'; $idx = 7 } } if ($target -eq 'back') { return } if ($target) { $r = & $invoke $target if ($target -eq 'apply') { return $r } # 'quit' bubbles to Start } } } finally { [Console]::CursorVisible = $true } } # --- Entry point -------------------------------------------------------------- function Start-PoshPalette { [CmdletBinding()] param() $items = @( @{ Key = '1'; Title = 'Simple mode'; Desc = 'Pick a full theme from a scrollable list'; Run = { Invoke-PoshPaletteSimpleMode } } @{ Key = '2'; Title = 'Detail mode'; Desc = 'Compose scheme, colors, prompt, font'; Run = { Invoke-PoshPaletteDetailMode } } @{ Key = '3'; Title = 'Doctor'; Desc = 'Check fonts, oh-my-posh, terminal'; Run = { Clear-Host; Test-PoshPaletteSetup | Out-Null; Write-Host "`n [Enter] back to menu" -ForegroundColor DarkGray; [Console]::ReadKey($true) | Out-Null } } @{ Key = 'Q'; Title = 'Quit'; Desc = 'Exit Posh Palette'; Run = { 'quit' } } ) $titleW = Get-PPMaxLen ($items | ForEach-Object { $_.Title }) $texts = $items | ForEach-Object { "[$($_.Key)] " + $_.Title.PadRight($titleW) + ' ' + $_.Desc } $rowW = Get-PPMaxLen $texts $idx = 0 [Console]::CursorVisible = $false try { while ($true) { Clear-Host Write-Host "" Write-Host " >_ " -ForegroundColor White -NoNewline Write-Host "Posh Palette" -ForegroundColor White Write-PPRule Write-Host " Style all 4 layers: scheme · PSReadLine · `$PSStyle · prompt" -ForegroundColor DarkGray Write-Host "" for ($i = 0; $i -lt $items.Count; $i++) { Write-PPRow ($i -eq $idx) $texts[$i] $rowW } Write-PPFooter @('↑/↓ move', 'Enter select', 'Q quit') $key = [Console]::ReadKey($true) # arrow navigation switch ($key.Key) { 'UpArrow' { $idx = ($idx - 1 + $items.Count) % $items.Count } 'DownArrow' { $idx = ($idx + 1) % $items.Count } 'Enter' { if ((& $items[$idx].Run) -eq 'quit') { return } } 'Escape' { return } } # number/letter shortcuts (arrow keys have KeyChar 0, so no double-fire) $ch = ([string]$key.KeyChar).ToUpper() $hit = $items | Where-Object { $_.Key -eq $ch } | Select-Object -First 1 if ($hit) { if ((& $hit.Run) -eq 'quit') { return } } } } finally { [Console]::CursorVisible = $true } } |