public/live/Invoke-SpectreLive.ps1
<# .SYNOPSIS Invokes a script block with live rendering. .DESCRIPTION Starts live rendering for a given renderable. The script block is able to update the renderable in real-time and Spectre Console redraws every time the scriptblock calls `$Context.refresh()`. See https://spectreconsole.net/live/live-display for more information. .PARAMETER Data The renderable object to render. .PARAMETER ScriptBlock The script block to execute while the live renderable is being rendered. .EXAMPLE # **Example 1** # This is a live updating table example, the table will be updated every second with a new row. $data = @( [pscustomobject]@{Name="John"; Age=25; City="New York"}, [pscustomobject]@{Name="Jane"; Age=30; City="Los Angeles"} ) $table = Format-SpectreTable -Data $data Invoke-SpectreLive -Data $table -ScriptBlock { param ( [Spectre.Console.LiveDisplayContext] $Context ) $Context.refresh() for ($i = 0; $i -lt 5; $i++) { Start-Sleep -Seconds 1 $table = Add-SpectreTableRow -Table $table -Columns "Shaun $i", $i, "Wellington" $Context.refresh() } } .EXAMPLE # **Example 2** # This is a complex live updating nested layout example. It demonstrates how to create a file browser with a preview panel. # The root layout is constructed with a header and a content panel. The content panel is split into two columns: filelist and preview. # Invoke-SpectreLive is used to render the layout and update the content of each panel on every loop iteration until the escape key is pressed. $layout = New-SpectreLayout -Name "root" -Rows @( # Row 1 ( New-SpectreLayout -Name "header" -MinimumSize 5 -Ratio 1 -Data ("empty") ), # Row 2 ( New-SpectreLayout -Name "content" -Ratio 10 -Columns @( ( New-SpectreLayout -Name "filelist" -Ratio 2 -Data "empty" ), ( New-SpectreLayout -Name "preview" -Ratio 4 -Data "empty" ) ) ) ) # Functions for rendering the content of each panel function Get-TitlePanel { return "File Browser - Spectre Live Demo [gray]$(Get-Date)[/]" | Format-SpectreAligned -HorizontalAlignment Center -VerticalAlignment Middle | Format-SpectrePanel -Expand } function Get-FileListPanel { param ( $Files, $SelectedFile ) $fileList = $Files | ForEach-Object { $name = $_.Name if ($_.Name -eq $SelectedFile.Name) { $name = "[Turquoise2]$($name)[/]" } return $name } | Out-String return Format-SpectrePanel -Header "[white]File List[/]" -Data $fileList.Trim() -Expand } function Get-PreviewPanel { param ( $SelectedFile ) $item = Get-Item -Path $SelectedFile.FullName $result = "" if ($item -is [System.IO.DirectoryInfo]) { $result = "[grey]$($SelectedFile.Name) is a directory.[/]" } elseif ($item.Name -match "\.(jpg|jpeg|png|gif)$") { $result = Get-SpectreSixelImage $item.FullName } else { try { $content = Get-Content -Path $item.FullName -Raw -ErrorAction Stop $result = "[grey]$($content | Get-SpectreEscapedText)[/]" } catch { $result = "[red]Error reading file content: $($_.Exception.Message | Get-SpectreEscapedText)[/]" } } return $result | Format-SpectrePanel -Header "[white]Preview[/]" -Expand } function Get-LastKeyPressed { $lastKeyPressed = $null while ([Console]::KeyAvailable) { $lastKeyPressed = [Console]::ReadKey($true) } return $lastKeyPressed } # Start live rendering the layout # Type "↓", "↓", "↓" to navigate the file list, and press "Enter" to open a file in Notepad Invoke-SpectreLive -Data $layout -ScriptBlock { param ( [Spectre.Console.LiveDisplayContext] $Context ) # State $fileList = @(@{Name = ".."; Fullname = ".."}) + (Get-ChildItem) $selectedFile = $fileList[0] while ($true) { # Handle input $lastKeyPressed = Get-LastKeyPressed if ($lastKeyPressed -ne $null) { if ($lastKeyPressed.Key -eq "DownArrow") { $selectedFile = $fileList[($fileList.IndexOf($selectedFile) + 1) % $fileList.Count] } elseif ($lastKeyPressed.Key -eq "UpArrow") { $selectedFile = $fileList[($fileList.IndexOf($selectedFile) - 1 + $fileList.Count) % $fileList.Count] } elseif ($lastKeyPressed.Key -eq "Enter") { if ($selectedFile -is [System.IO.DirectoryInfo] -or $selectedFile.Name -eq "..") { $fileList = @(@{Name = ".."; Fullname = ".."}) + (Get-ChildItem -Path $selectedFile.FullName) $selectedFile = $fileList[0] } else { notepad $selectedFile.FullName return } } elseif ($lastKeyPressed.Key -eq "Escape") { return } } # Generate new data $titlePanel = Get-TitlePanel $fileListPanel = Get-FileListPanel -Files $fileList -SelectedFile $selectedFile $previewPanel = Get-PreviewPanel -SelectedFile $selectedFile # Update layout $layout["header"].Update($titlePanel) | Out-Null $layout["filelist"].Update($fileListPanel) | Out-Null $layout["preview"].Update($previewPanel) | Out-Null # Draw changes $Context.Refresh() Start-Sleep -Milliseconds 200 } } .EXAMPLE # **Example 3** # This is a simple example of creating a chat application. In this example a different approach is used to render the components, each component has been passed a copy of the context and layout object so it can update itself. Set-SpectreColors -AccentColor DeepPink1 # Build root layout scaffolding for: # +--------------------------------+ # | Title | <- Update-TitleComponent will render the title # |--------------------------------| # | | <- Update-MessageListComponent will display the list of messages here # | | # | Messages | # | | # | | # |--------------------------------| # | CustomTextEntry | <- Update-CustomTextEntryComponent will create a text entry prompt here that is manually managed by pushing keys into a string # |________________________________| $layout = New-SpectreLayout -Name "root" -Rows @( # Row 1 (New-SpectreLayout -Name "title" -MinimumSize 5 -Ratio 1 -Data ("empty")), # Row 2 (New-SpectreLayout -Name "messages" -Ratio 10 -Data ("empty")), # Row 3 (New-SpectreLayout -Name "customTextEntry" -MinimumSize 5 -Ratio 1 -Data ("empty")) ) # Component functions for rendering the content of each panel function Update-TitleComponent { param ( [Spectre.Console.LiveDisplayContext] $Context, [Spectre.Console.Layout] $LayoutComponent ) $component = @( ("🧠 ChaTTY" | Format-SpectreAligned -HorizontalAlignment Center -VerticalAlignment Middle | Format-SpectrePadded -Padding 1), (Write-SpectreRule -LineColor DeepPink1 -PassThru) ) | Format-SpectreRows | Format-SpectrePanel -Border None $LayoutComponent.Update($component) | Out-Null $Context.Refresh() } function Update-MessageListComponent { param ( [Spectre.Console.LiveDisplayContext] $Context, [Spectre.Console.Layout] $LayoutComponent, [System.Collections.Stack] $Messages ) $rows = @() foreach ($message in $Messages) { if ($message.Actor -eq "System") { $rows += $message.Message.PadRight(6) ` | Get-SpectreEscapedText ` | Write-SpectreHost -Justify Left -PassThru ` | Format-SpectrePanel -Color Grey -Header "System" ` | Format-SpectreAligned -HorizontalAlignment Left ` | Format-SpectrePadded -Top 0 -Left 10 -Bottom 0 -Right 0 } else { $rows += $message.Message.PadRight($message.Actor.Length) ` | Get-SpectreEscapedText ` | Write-SpectreHost -Justify Right -PassThru ` | Format-SpectrePanel -Color Pink1 -Header $message.Actor ` | Format-SpectreAligned -HorizontalAlignment Right ` | Format-SpectrePadded -Top 0 -Left 0 -Bottom 0 -Right 10 } } # Add the heights of each message until reaching the max size, subtract the height of the title and text entry components (10) $availableHeight = $Host.UI.RawUI.WindowSize.Height - 10 $totalHeight = 0 $rowsToRender = @() foreach ($row in $rows) { $totalHeight += ($row | Get-SpectreRenderableSize).Height if ($totalHeight -gt $availableHeight) { break } $rowsToRender += $row } # Stack is LIFO, so we need to reverse it to display the messages in the correct order [array]::Reverse($rowsToRender) $component = $rowsToRender | Format-SpectreRows | Format-SpectreAligned -VerticalAlignment Top | Format-SpectrePanel -Border None $LayoutComponent.Update($component) | Out-Null $Context.Refresh() } function Update-CustomTextEntryComponent { param ( [Spectre.Console.LiveDisplayContext] $Context, [Spectre.Console.Layout] $LayoutComponent, [string] $CurrentInput ) $safeInput = [string]::IsNullOrEmpty($CurrentInput) ? "" : ($CurrentInput | Get-SpectreEscapedText) $component = "[gray]Prompt:[/] $safeInput" | Format-SpectrePanel -Expand | Format-SpectrePadded -Top 0 -Left 20 -Bottom 0 -Right 20 | Format-SpectreAligned -HorizontalAlignment Center $LayoutComponent.Update($component) | Out-Null $Context.Refresh() } # App logic functions function Get-SomeChatResponse { param ( [System.Collections.Stack] $Messages, [Spectre.Console.LiveDisplayContext] $Context, [Spectre.Console.Layout] $LayoutComponent ) # Pretend to be thinking $ellipsisCount = 1 for ($i = 0; $i -lt 3; $i++) { $Messages.Push(@{ Actor = "System"; Message = ("." * $ellipsisCount) }) $ellipsisCount++ Update-MessageListComponent -Context $Context -LayoutComponent $LayoutComponent -Messages $Messages Start-Sleep -Milliseconds 500 # Remove the last thinking message $null = $Messages.Pop() } # Return the response return @{ Actor = "System"; Message = "I don't understand what you're saying." } } function Get-LastChatKeyPressed { return [Console]::ReadKey($true) } # Start live rendering the layout Invoke-SpectreLive -Data $layout -ScriptBlock { param ( [Spectre.Console.LiveDisplayContext] $Context ) # State $messages = [System.Collections.Stack]::new(@( @{ Actor = "System"; Message = "👋 Hello, welcome to ChaTTY!" }, @{ Actor = "System"; Message = "Type your message and press Enter to send it." }, @{ Actor = "System"; Message = "Use the Up and Down arrow keys to scroll through previous messages." }, @{ Actor = "System"; Message = "Press 'ctrl-c' to close the chat." } )) $currentInput = "" while ($true) { # Update components Update-TitleComponent -Context $Context -LayoutComponent $layout["title"] Update-MessageListComponent -Context $Context -LayoutComponent $layout["messages"] -Messages $messages Update-CustomTextEntryComponent -Context $Context -LayoutComponent $layout["customTextEntry"] -CurrentInput $currentInput # Real basic input handling, just add characters and remove if backspace is pressed, submit message if Enter is pressed [Console]::TreatControlCAsInput = $true $lastKeyPressed = Get-LastChatKeyPressed if ($lastKeyPressed.Key -eq "C" -and $lastKeyPressed.Modifiers -eq "Control") { # Exit the loop. You have to treat ctrl-c as input to avoid the console readkey blocking the sigint return } elseif ($lastKeyPressed.Key -eq "Enter") { # Add the latest user message to the message stack $messages.Push(@{ Actor = ($env:USERNAME + $env:USER); Message = $currentInput }) $currentInput = "" Update-CustomTextEntryComponent -Context $Context -LayoutComponent $layout["customTextEntry"] -CurrentInput $currentInput Update-MessageListComponent -Context $Context -LayoutComponent $layout["messages"] -Messages $messages $messages.Push((Get-SomeChatResponse -Messages $messages -Context $Context -LayoutComponent $layout["messages"])) } elseif($lastKeyPressed.Key -eq "Backspace") { # Remove the last character from the current input string $currentInput = $currentInput.Substring(0, [Math]::Max(0, $currentInput.Length - 1)) } elseif ($lastKeyPressed.KeyChar) { # Add the character to the current input string $currentInput += $lastKeyPressed.KeyChar } } } #> function Invoke-SpectreLive { [Reflection.AssemblyMetadata("title", "Invoke-SpectreLive")] param ( [Parameter(ValueFromPipeline)] [RenderableTransformationAttribute()] [object] $Data, [scriptblock] $ScriptBlock ) Start-AnsiConsoleLive -Data $Data -ScriptBlock $ScriptBlock } |