private/output/New-ConsoleTabFull.ps1
|
function New-ConsoleTabFull { <# .SYNOPSIS Creates the full Console tab with RichTextBox, toolbar, and text search. #> [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$Colors ) # Create Console tab (initially hidden until it has content) $consoleTab = [System.Windows.Controls.TabItem]@{ Header = "Console" Visibility = 'Collapsed' } Set-TabItemStyle -TabItem $consoleTab # Container for console toolbar and output $consoleContainer = [System.Windows.Controls.DockPanel]::new() # Console toolbar - use DockPanel for left/right alignment $consoleToolbar = [System.Windows.Controls.DockPanel]@{ Margin = [System.Windows.Thickness]::new(4, 4, 4, 4) Height = 32 } [System.Windows.Controls.DockPanel]::SetDock($consoleToolbar, [System.Windows.Controls.Dock]::Top) $leftToolbarPanel = [System.Windows.Controls.StackPanel]@{ Orientation = 'Horizontal' VerticalAlignment = 'Center' } [System.Windows.Controls.DockPanel]::SetDock($leftToolbarPanel, 'Left') $autoScrollCheckbox = [System.Windows.Controls.CheckBox]@{ Content = 'Auto-scroll' IsChecked = $true Margin = [System.Windows.Thickness]::new(0, 0, 10, 0) VerticalAlignment = 'Center' VerticalContentAlignment = 'Center' } Set-CheckBoxStyle -CheckBox $autoScrollCheckbox [void]$leftToolbarPanel.Children.Add($autoScrollCheckbox) $wrapCheckbox = [System.Windows.Controls.CheckBox]@{ Content = 'Wrap' IsChecked = $true Margin = [System.Windows.Thickness]::new(0, 0, 10, 0) VerticalAlignment = 'Center' VerticalContentAlignment = 'Center' ToolTip = 'Toggle word wrapping' } Set-CheckBoxStyle -CheckBox $wrapCheckbox [void]$leftToolbarPanel.Children.Add($wrapCheckbox) $pinToTopCheckbox = [System.Windows.Controls.CheckBox]@{ Content = 'Pin' IsChecked = $false Margin = [System.Windows.Thickness]::new(0, 0, 10, 0) VerticalAlignment = 'Center' VerticalContentAlignment = 'Center' ToolTip = 'Keep window on top of other windows' } Set-CheckBoxStyle -CheckBox $pinToTopCheckbox [void]$leftToolbarPanel.Children.Add($pinToTopCheckbox) $timestampsCheckbox = [System.Windows.Controls.CheckBox]@{ Content = 'Time' IsChecked = $false Margin = [System.Windows.Thickness]::new(0, 0, 6, 0) VerticalAlignment = 'Center' VerticalContentAlignment = 'Center' ToolTip = 'Show timestamps on console output' } Set-CheckBoxStyle -CheckBox $timestampsCheckbox [void]$leftToolbarPanel.Children.Add($timestampsCheckbox) # Vertical separator between toggles and action buttons $toolbarSeparator = [System.Windows.Controls.Border]@{ Width = 1 Height = 18 Margin = [System.Windows.Thickness]::new(4, 0, 10, 0) Background = ConvertTo-UiBrush $Colors.Border VerticalAlignment = 'Center' } [void]$leftToolbarPanel.Children.Add($toolbarSeparator) # Action buttons $copyAllButton = [System.Windows.Controls.Button]@{ Content = 'Copy All' Padding = [System.Windows.Thickness]::new(8, 2, 8, 2) Margin = [System.Windows.Thickness]::new(0, 0, 4, 0) VerticalAlignment = 'Center' } Set-ButtonStyle -Button $copyAllButton [void]$leftToolbarPanel.Children.Add($copyAllButton) $saveButton = [System.Windows.Controls.Button]@{ Content = 'Save' Padding = [System.Windows.Thickness]::new(8, 2, 8, 2) Margin = [System.Windows.Thickness]::new(0, 0, 4, 0) VerticalAlignment = 'Center' } Set-ButtonStyle -Button $saveButton [void]$leftToolbarPanel.Children.Add($saveButton) $clearConsoleButton = [System.Windows.Controls.Button]@{ Content = 'Clear' Padding = [System.Windows.Thickness]::new(8, 2, 8, 2) Margin = [System.Windows.Thickness]::new(0, 0, 4, 0) VerticalAlignment = 'Center' } Set-ButtonStyle -Button $clearConsoleButton [void]$leftToolbarPanel.Children.Add($clearConsoleButton) # Vertical separator before font size control $fontSeparator = [System.Windows.Controls.Border]@{ Width = 1 Height = 18 Margin = [System.Windows.Thickness]::new(8, 0, 12, 4) Background = ConvertTo-UiBrush $Colors.Border VerticalAlignment = 'Center' } [void]$leftToolbarPanel.Children.Add($fontSeparator) # Font size slider with label beneath $defaultFontSize = 12 $fontSizePanel = [System.Windows.Controls.StackPanel]@{ Orientation = 'Vertical' VerticalAlignment = 'Center' } $fontSizeSlider = [System.Windows.Controls.Slider]@{ Minimum = 8 Maximum = 24 Value = $defaultFontSize Width = 70 TickFrequency = 1 IsSnapToTickEnabled = $true ToolTip = 'Adjust font size (8-24pt). Ctrl+Scroll to change. Double-click to reset.' Tag = $defaultFontSize } Set-SliderStyle -Slider $fontSizeSlider [void]$fontSizePanel.Children.Add($fontSizeSlider) $fontSizeLabel = [System.Windows.Controls.TextBlock]@{ Text = 'Font Size' HorizontalAlignment = 'Center' FontSize = 8 Margin = [System.Windows.Thickness]::new(-15, -4.5, 0, 0) Foreground = ConvertTo-UiBrush $Colors.SecondaryText } [void]$fontSizePanel.Children.Add($fontSizeLabel) [void]$leftToolbarPanel.Children.Add($fontSizePanel) [void]$consoleToolbar.Children.Add($leftToolbarPanel) $findPanel = [System.Windows.Controls.StackPanel]@{ Orientation = 'Horizontal' HorizontalAlignment = 'Right' VerticalAlignment = 'Center' } [System.Windows.Controls.DockPanel]::SetDock($findPanel, 'Right') # Match count label (first, before icon) $findMatchLabel = [System.Windows.Controls.TextBlock]@{ Text = '' FontSize = 11 VerticalAlignment = 'Center' Foreground = ConvertTo-UiBrush $Colors.SecondaryText Margin = [System.Windows.Thickness]::new(0, 0, 10, 0) MinWidth = 50 TextAlignment = 'Right' Visibility = 'Collapsed' } [void]$findPanel.Children.Add($findMatchLabel) $findIcon = [System.Windows.Controls.TextBlock]@{ Text = [PsUi.ModuleContext]::GetIcon('Search') FontFamily = [System.Windows.Media.FontFamily]::new('Segoe MDL2 Assets') FontSize = 14 VerticalAlignment = 'Center' Foreground = ConvertTo-UiBrush $Colors.ControlFg Margin = [System.Windows.Thickness]::new(0, 0, 6, 0) } [void]$findPanel.Children.Add($findIcon) # Create filter box with clear button $findBoxResult = New-FilterBoxWithClear -Width 150 -Height 24 $findBoxContainer = $findBoxResult.Container $consoleFindBox = $findBoxResult.TextBox [void]$findPanel.Children.Add($findBoxContainer) $findPrevBtn = [System.Windows.Controls.Button]@{ Content = [PsUi.ModuleContext]::GetIcon('ChevronUp') FontFamily = [System.Windows.Media.FontFamily]::new('Segoe MDL2 Assets') Width = 24 Height = 24 Padding = [System.Windows.Thickness]::new(0) Margin = [System.Windows.Thickness]::new(4, 0, 0, 0) ToolTip = 'Previous match' VerticalAlignment = 'Center' } Set-ButtonStyle -Button $findPrevBtn [void]$findPanel.Children.Add($findPrevBtn) $findNextBtn = [System.Windows.Controls.Button]@{ Content = [PsUi.ModuleContext]::GetIcon('ChevronDown') FontFamily = [System.Windows.Media.FontFamily]::new('Segoe MDL2 Assets') Width = 24 Height = 24 Padding = [System.Windows.Thickness]::new(0) Margin = [System.Windows.Thickness]::new(2, 0, 0, 0) ToolTip = 'Next match' VerticalAlignment = 'Center' } Set-ButtonStyle -Button $findNextBtn [void]$findPanel.Children.Add($findNextBtn) [void]$consoleToolbar.Children.Add($findPanel) [void]$consoleContainer.Children.Add($consoleToolbar) # Console RichTextBox for colored output $highlightColor = if ($Colors.TextHighlight) { $Colors.TextHighlight } else { $Colors.Selection } $consoleTextBox = [System.Windows.Controls.RichTextBox]@{ IsReadOnly = $true FontFamily = [System.Windows.Media.FontFamily]::new('Cascadia Code, Cascadia Mono, Consolas, Courier New') VerticalScrollBarVisibility = 'Auto' HorizontalScrollBarVisibility = 'Auto' Background = ConvertTo-UiBrush $Colors.ControlBg Foreground = ConvertTo-UiBrush $Colors.ControlFg BorderBrush = ConvertTo-UiBrush $Colors.Border BorderThickness = [System.Windows.Thickness]::new(0) Padding = [System.Windows.Thickness]::new(8, 4, 8, 4) SelectionBrush = ConvertTo-UiBrush $highlightColor } [void]$consoleContainer.Children.Add($consoleTextBox) # Create FlowDocument with matching style $consoleDocument = [System.Windows.Documents.FlowDocument]@{ FontFamily = [System.Windows.Media.FontFamily]::new('Cascadia Code, Cascadia Mono, Consolas, Courier New') FontSize = 12 Foreground = ConvertTo-UiBrush $Colors.ControlFg Background = ConvertTo-UiBrush $Colors.ControlBg PagePadding = [System.Windows.Thickness]::new(0) } # Track max content width for no-wrap mode $maxLineWidth = @{ Value = 0 } $consoleParagraph = [System.Windows.Documents.Paragraph]@{ Margin = [System.Windows.Thickness]::new(0) LineHeight = 16 LineStackingStrategy = [System.Windows.LineStackingStrategy]::BlockLineHeight } [void]$consoleDocument.Blocks.Add($consoleParagraph) $consoleTextBox.Document = $consoleDocument # Text search brushes and state $highlightBrush = if ($Colors.FindHighlight) { ConvertTo-UiBrush $Colors.FindHighlight } else { [System.Windows.Media.Brushes]::Gold } $currentBrush = if ($Colors.Accent) { ConvertTo-UiBrush $Colors.Accent } else { [System.Windows.Media.Brushes]::Orange } # Create debounce timer $debounceTimer = [System.Windows.Threading.DispatcherTimer]::new() $debounceTimer.Interval = [TimeSpan]::FromMilliseconds(300) # Merge clear button and watermark references into the Tag $originalTag = $consoleFindBox.Tag $findState = @{ Paragraph = $consoleParagraph TextBox = $consoleTextBox Label = $findMatchLabel HighlightBrush = $highlightBrush CurrentBrush = $currentBrush ResetBrush = [System.Windows.Media.Brushes]::Transparent Matches = [System.Collections.Generic.List[object]]::new() Index = -1 Timer = $debounceTimer FindBox = $consoleFindBox SearchTerm = '' AutoScrollCheckbox = $autoScrollCheckbox ClearButton = $originalTag.ClearButton Watermark = $originalTag.Watermark } $consoleFindBox.Tag = $findState # Store references for nav buttons and timer $findPrevBtn.Tag = $consoleFindBox $findNextBtn.Tag = $consoleFindBox $debounceTimer.Tag = $consoleFindBox # Search function extracted for reuse $doSearch = { param($findBox) $state = $findBox.Tag $paragraph = $state.Paragraph $term = $findBox.Text # Store current search term for live highlighting $state.SearchTerm = $term # Clear previous highlights foreach ($prevRange in $state.Matches) { try { $prevRange.ApplyPropertyValue([System.Windows.Documents.TextElement]::BackgroundProperty, $state.ResetBrush) } catch { Write-Debug "Suppressed highlight reset error: $_" } } $state.Matches.Clear() $state.Index = -1 if ([string]::IsNullOrEmpty($term)) { $state.Label.Text = '' $state.Label.Visibility = 'Hidden' return } # Check document size and warn if too large $inlinesCopy = @($paragraph.Inlines) $tooLarge = $inlinesCopy.Count -gt 2000 # Buffer ranges to highlight $rangesToHighlight = [System.Collections.Generic.List[object]]::new() $termLen = $term.Length $maxMatches = if ($tooLarge) { 100 } else { 500 } $matchLimitReached = $false # Build concatenated text from all Runs with position mapping $textBuilder = [System.Text.StringBuilder]::new() $runMap = [System.Collections.Generic.List[object]]::new() foreach ($inline in $inlinesCopy) { if ($inline -isnot [System.Windows.Documents.Run]) { continue } $runText = $inline.Text if ([string]::IsNullOrEmpty($runText)) { continue } $startPos = $textBuilder.Length [void]$textBuilder.Append($runText) [void]$runMap.Add(@{ Run = $inline; Start = $startPos; Length = $runText.Length }) } $fullText = $textBuilder.ToString() # Search in full text $offset = 0 :searchLoop while (($ix = $fullText.IndexOf($term, $offset, [StringComparison]::OrdinalIgnoreCase)) -ge 0) { $matchEnd = $ix + $termLen # Find which Run(s) this match spans foreach ($entry in $runMap) { $runStart = $entry.Start $runEnd = $runStart + $entry.Length $run = $entry.Run if ($matchEnd -le $runStart) { continue } if ($ix -ge $runEnd) { continue } $localStart = [Math]::Max(0, $ix - $runStart) $localEnd = [Math]::Min($entry.Length, $matchEnd - $runStart) $ptrStart = $run.ContentStart.GetPositionAtOffset($localStart, [System.Windows.Documents.LogicalDirection]::Forward) $ptrEnd = $run.ContentStart.GetPositionAtOffset($localEnd, [System.Windows.Documents.LogicalDirection]::Forward) if ($ptrStart -and $ptrEnd) { $range = [System.Windows.Documents.TextRange]::new($ptrStart, $ptrEnd) [void]$rangesToHighlight.Add($range) } } if ($rangesToHighlight.Count -ge $maxMatches) { $matchLimitReached = $true break searchLoop } $offset = $ix + $termLen } # Apply highlights foreach ($range in $rangesToHighlight) { $range.ApplyPropertyValue([System.Windows.Documents.TextElement]::BackgroundProperty, $state.HighlightBrush) } # Store matches and update UI $state.Matches = $rangesToHighlight if ($rangesToHighlight.Count -gt 0) { $state.Index = 0 $rangesToHighlight[0].ApplyPropertyValue([System.Windows.Documents.TextElement]::BackgroundProperty, $state.CurrentBrush) $state.Label.Visibility = 'Visible' if ($matchLimitReached) { $state.Label.Text = "1 of $maxMatches+ (limit)" } else { $state.Label.Text = "1 of $($rangesToHighlight.Count)" } try { $rect = $rangesToHighlight[0].Start.GetCharacterRect([System.Windows.Documents.LogicalDirection]::Forward) $state.TextBox.ScrollToVerticalOffset($state.TextBox.VerticalOffset + $rect.Top - 100) } catch { Write-Debug "Suppressed scroll to first match error: $_" } } else { $state.Label.Visibility = 'Visible' $state.Label.Text = 'No matches' } } # Helper to highlight matches in a single Run (for live streaming) $highlightRunMatches = { param([System.Windows.Documents.Run]$Run, $FindState) if (!$FindState -or [string]::IsNullOrEmpty($FindState.SearchTerm)) { return } $term = $FindState.SearchTerm $termLen = $term.Length $text = $Run.Text if ([string]::IsNullOrEmpty($text)) { return } # Buffer ranges first $rangesToAdd = [System.Collections.Generic.List[object]]::new() $offset = 0 while (($ix = $text.IndexOf($term, $offset, [StringComparison]::CurrentCultureIgnoreCase)) -ge 0) { $ptrStart = $Run.ContentStart.GetPositionAtOffset($ix, [System.Windows.Documents.LogicalDirection]::Forward) $ptrEnd = $Run.ContentStart.GetPositionAtOffset($ix + $termLen, [System.Windows.Documents.LogicalDirection]::Backward) if ($ptrStart -and $ptrEnd) { $range = [System.Windows.Documents.TextRange]::new($ptrStart, $ptrEnd) [void]$rangesToAdd.Add($range) } $offset = $ix + $termLen } # Apply highlights foreach ($range in $rangesToAdd) { $range.ApplyPropertyValue([System.Windows.Documents.TextElement]::BackgroundProperty, $FindState.HighlightBrush) [void]$FindState.Matches.Add($range) } # Update match count label if ($FindState.Matches.Count -gt 0) { $FindState.Label.Text = "$($FindState.Index + 1) of $($FindState.Matches.Count)" } } # Store search function in Tag $findState.DoSearch = $doSearch # Timer tick handler $debounceTimer.Add_Tick({ param($sender, $eventArgs) $sender.Stop() $fb = $sender.Tag $state = $fb.Tag & $state.DoSearch $fb }.GetNewClosure()) # Auto-search on text change with debounce $consoleFindBox.Add_TextChanged({ param($sender, $eventArgs) $state = $sender.Tag $state.Timer.Stop() $state.Timer.Start() }.GetNewClosure()) # Prev button $findPrevBtn.Add_Click({ param($sender, $eventArgs) $fb = $sender.Tag $state = $fb.Tag if ($state.Matches.Count -eq 0) { return } # Disable auto-scroll when navigating if ($state.AutoScrollCheckbox.IsChecked) { $state.AutoScrollCheckbox.IsChecked = $false } # Reset current highlight $state.Matches[$state.Index].ApplyPropertyValue([System.Windows.Documents.TextElement]::BackgroundProperty, $state.HighlightBrush) $state.Index = $state.Index - 1 if ($state.Index -lt 0) { $state.Index = $state.Matches.Count - 1 } # Highlight new current $cur = $state.Matches[$state.Index] $cur.ApplyPropertyValue([System.Windows.Documents.TextElement]::BackgroundProperty, $state.CurrentBrush) $state.Label.Text = "$($state.Index + 1) of $($state.Matches.Count)" try { $rect = $cur.Start.GetCharacterRect([System.Windows.Documents.LogicalDirection]::Forward) $state.TextBox.ScrollToVerticalOffset($state.TextBox.VerticalOffset + $rect.Top - 100) } catch { Write-Debug "Suppressed scroll to prev match error: $_" } }.GetNewClosure()) # Next button $findNextBtn.Add_Click({ param($sender, $eventArgs) $fb = $sender.Tag $state = $fb.Tag if ($state.Matches.Count -eq 0) { return } # Disable auto-scroll when navigating if ($state.AutoScrollCheckbox.IsChecked) { $state.AutoScrollCheckbox.IsChecked = $false } # Reset current highlight $state.Matches[$state.Index].ApplyPropertyValue([System.Windows.Documents.TextElement]::BackgroundProperty, $state.HighlightBrush) $state.Index = $state.Index + 1 if ($state.Index -ge $state.Matches.Count) { $state.Index = 0 } # Highlight new current $cur = $state.Matches[$state.Index] $cur.ApplyPropertyValue([System.Windows.Documents.TextElement]::BackgroundProperty, $state.CurrentBrush) $state.Label.Text = "$($state.Index + 1) of $($state.Matches.Count)" try { $rect = $cur.Start.GetCharacterRect([System.Windows.Documents.LogicalDirection]::Forward) $state.TextBox.ScrollToVerticalOffset($state.TextBox.VerticalOffset + $rect.Top - 100) } catch { Write-Debug "Suppressed scroll to next match error: $_" } }.GetNewClosure()) # Map console colors to WPF brushes (adjusted for dark bg readability) $consoleColorMap = Get-ConsoleColorBrushMap # Raw colors used when user explicitly sets -BackgroundColor (they've handled contrast) $rawColorMap = Get-RawConsoleColorBrushMap # Helper to append colored text $appendConsoleText = { param([string]$Text, [System.Windows.Media.Brush]$Color, [System.Windows.Media.Brush]$BackColor, [switch]$SkipScroll, [switch]$NoNewLine, $State) # Newline-only text (from WriteLine after colored Write) if ($Text -eq "`n" -or $Text -eq "`r`n") { [void]$State.Paragraph.Inlines.Add([System.Windows.Documents.LineBreak]::new()) $State.AtLineStart.Value = $true if (!$SkipScroll -and $State.AutoScrollCheckbox.IsChecked) { $State.TextBox.ScrollToEnd() } return } if ([string]::IsNullOrEmpty($Text)) { return } $charWidth = 7.2 $paragraph = $State.Paragraph $textBox = $State.TextBox $document = $State.Document $wrap = $State.WrapCheckbox $autoScrl = $State.AutoScrollCheckbox $tsCheckbox = $State.TimestampsCheckbox $atLineStart = $State.AtLineStart $findState = $State.FindState $maxWidth = $State.MaxLineWidth $hlFunc = $State.HighlightRunMatches # Capture current timestamp for this batch of output $currentTimestamp = Get-Date # Helper to prepend timestamp if enabled and at line start $prependTimestamp = { if ($tsCheckbox.IsChecked -and $atLineStart.Value) { $ts = '[' + $currentTimestamp.ToString('HH:mm:ss') + '] ' $tsRun = [System.Windows.Documents.Run]::new($ts) $tsRun.Foreground = [System.Windows.Media.Brushes]::Gray $tsRun.Tag = 'TS' # Mark as timestamp run for removal [void]$paragraph.Inlines.Add($tsRun) } $atLineStart.Value = $false } # Helper to store timestamp in a run's Tag $storeTimestamp = { param($targetRun) $targetRun.Tag = $currentTimestamp } # Backspace for spinner patterns (e.g., Write-Host "`b|" -NoNewline) # Only triggers when text literally starts with backspace character if ($Text[0] -eq [char]8) { $backspaceCount = 0 $idx = 0 while ($idx -lt $Text.Length -and $Text[$idx] -eq [char]8) { $backspaceCount++ $idx++ } $cleanText = $Text.Substring($backspaceCount) # Find the last Run, skipping trailing LineBreaks # (NoNewLine flag may not be preserved correctly through event chain) $lastRun = $null $current = $paragraph.Inlines.LastInline $skippedLineBreak = $false while ($null -ne $current) { if ($current -is [System.Windows.Documents.LineBreak]) { # Skip LineBreak and keep looking for a Run $skippedLineBreak = $true $current = $current.PreviousInline continue } if ($current -is [System.Windows.Documents.Run]) { $lastRun = $current break } $current = $current.PreviousInline } # Remove characters from the end of the last run if ($lastRun -and $lastRun.Text.Length -gt 0) { $removeCount = [Math]::Min($backspaceCount, $lastRun.Text.Length) $lastRun.Text = $lastRun.Text.Substring(0, $lastRun.Text.Length - $removeCount) # If we skipped a LineBreak, remove it so the new text continues on same line if ($skippedLineBreak) { $lb = $paragraph.Inlines.LastInline if ($lb -is [System.Windows.Documents.LineBreak]) { $paragraph.Inlines.Remove($lb) } } } # Append the new text if any if (![string]::IsNullOrEmpty($cleanText)) { & $prependTimestamp $run = [System.Windows.Documents.Run]::new($cleanText) if ($Color) { $run.Foreground = $Color } if ($BackColor) { $run.Background = $BackColor } & $storeTimestamp $run [void]$paragraph.Inlines.Add($run) } # Add LineBreak if NoNewLine is not set if (!$NoNewLine) { [void]$paragraph.Inlines.Add([System.Windows.Documents.LineBreak]::new()) $atLineStart.Value = $true } if (!$SkipScroll -and $autoScrl.IsChecked) { $textBox.ScrollToEnd() } return } # Carriage return for line replacement (e.g., Write-Host "`rProgress: 50%" -NoNewline) if ($Text[0] -eq [char]13 -and ($Text.Length -eq 1 -or $Text[1] -ne [char]10)) { # Strip leading CR characters $idx = 0 while ($idx -lt $Text.Length -and $Text[$idx] -eq [char]13) { $idx++ } $cleanText = if ($idx -lt $Text.Length) { $Text.Substring($idx) } else { '' } # Remove all inlines on the current line (after last LineBreak) $inlinesToRemove = [System.Collections.Generic.List[object]]::new() $current = $paragraph.Inlines.LastInline while ($null -ne $current) { if ($current -is [System.Windows.Documents.LineBreak]) { break } $inlinesToRemove.Add($current) $current = $current.PreviousInline } foreach ($inline in $inlinesToRemove) { $paragraph.Inlines.Remove($inline) } # We're now at the start of the line $atLineStart.Value = $true # Append the new text if any (recursively handle remaining text) if (![string]::IsNullOrEmpty($cleanText)) { & $appendConsoleText $cleanText $Color $BackColor -SkipScroll:$SkipScroll -NoNewLine:$NoNewLine -State $State } elseif (!$NoNewLine) { # Just CR with no text and no NoNewLine - add a line break [void]$paragraph.Inlines.Add([System.Windows.Documents.LineBreak]::new()) } if (!$SkipScroll -and $autoScrl.IsChecked) { $textBox.ScrollToEnd() } return } # Split into lines $lines = $Text -split "`r?`n" $lineCount = $lines.Count $lineIndex = 0 foreach ($lineText in $lines) { $lineIndex++ if ([string]::IsNullOrEmpty($lineText)) { continue } # Prepend timestamp if at start of line and timestamps enabled & $prependTimestamp # Create Run with just the text (no embedded newline - those don't work in FlowDocument) $run = [System.Windows.Documents.Run]::new($lineText) if ($Color) { $run.Foreground = $Color } if ($BackColor) { $run.Background = $BackColor } & $storeTimestamp $run [void]$paragraph.Inlines.Add($run) # Add LineBreak element unless NoNewLine is set and this is the last line $needsNewLine = !($NoNewLine -and $lineIndex -eq $lineCount) if ($needsNewLine) { [void]$paragraph.Inlines.Add([System.Windows.Documents.LineBreak]::new()) $atLineStart.Value = $true } # Track max width for no-wrap mode $estimatedWidth = $lineText.Length * $charWidth if ($estimatedWidth -gt $maxWidth.Value) { $maxWidth.Value = $estimatedWidth if (!$wrap.IsChecked) { $minWidth = [Math]::Max($maxWidth.Value + 50, $textBox.ActualWidth) $document.PageWidth = $minWidth } } # Highlight matches in new runs if search is active if ($hlFunc -and $findState) { & $hlFunc $run $findState } } # Auto-scroll if enabled if (!$SkipScroll -and $autoScrl.IsChecked) { $textBox.ScrollToEnd() } } # Font size slider changes document and paragraph font sizes, plus line height $fontSizeSlider.Add_ValueChanged({ $fontSize = $fontSizeSlider.Value $consoleDocument.FontSize = $fontSize $consoleParagraph.FontSize = $fontSize $consoleParagraph.LineHeight = [Math]::Round($fontSize * 1.33) }.GetNewClosure()) # Double-click slider to reset to default (use Preview to catch before thumb handles it) $fontSizeSlider.Add_PreviewMouseDoubleClick({ $fontSizeSlider.Value = $fontSizeSlider.Tag }.GetNewClosure()) # Ctrl+scroll to change font size, regular scroll disables auto-scroll $consoleTextBox.Add_PreviewMouseWheel({ param($sender, $wheelArgs) if ([System.Windows.Input.Keyboard]::Modifiers -eq 'Control') { # Ctrl held - adjust font size $wheelArgs.Handled = $true $delta = if ($wheelArgs.Delta -gt 0) { 1 } else { -1 } $newValue = $fontSizeSlider.Value + $delta # Clamp to slider bounds if ($newValue -ge $fontSizeSlider.Minimum -and $newValue -le $fontSizeSlider.Maximum) { $fontSizeSlider.Value = $newValue } } else { # Normal scroll - disable auto-scroll if ($autoScrollCheckbox.IsChecked) { $autoScrollCheckbox.IsChecked = $false } } }.GetNewClosure()) # Wire up word wrap checkbox $wrapCheckbox.Add_Checked({ $consoleDocument.PageWidth = [Double]::NaN $consoleTextBox.HorizontalScrollBarVisibility = 'Auto' }.GetNewClosure()) $wrapCheckbox.Add_Unchecked({ $minWidth = [Math]::Max($maxLineWidth.Value + 50, $consoleTextBox.ActualWidth) $consoleDocument.PageWidth = $minWidth $consoleTextBox.HorizontalScrollBarVisibility = 'Auto' }.GetNewClosure()) # Show timestamps retroactively when checkbox is checked $timestampsCheckbox.Add_Checked({ $inlines = $consoleParagraph.Inlines $toInsert = [System.Collections.Generic.List[object]]::new() $atStart = $true foreach ($inline in $inlines) { if ($inline -is [System.Windows.Documents.LineBreak]) { $atStart = $true continue } if ($inline -is [System.Windows.Documents.Run] -and $inline.Tag -ne 'TS') { if ($atStart -and $inline.Tag -is [datetime]) { $toInsert.Add(@{ Before = $inline; Timestamp = $inline.Tag.ToString('HH:mm:ss') }) } $atStart = $false } } # Insert after iteration to avoid modifying collection during enumeration foreach ($item in $toInsert) { $tsRun = [System.Windows.Documents.Run]::new('[' + $item.Timestamp + '] ') $tsRun.Foreground = [System.Windows.Media.Brushes]::Gray $tsRun.Tag = 'TS' $inlines.InsertBefore($item.Before, $tsRun) } }.GetNewClosure()) # Remove timestamps when checkbox is unchecked $timestampsCheckbox.Add_Unchecked({ $inlines = $consoleParagraph.Inlines $toRemove = [System.Collections.Generic.List[System.Windows.Documents.Run]]::new() foreach ($inline in $inlines) { if ($inline -is [System.Windows.Documents.Run] -and $inline.Tag -eq 'TS') { $toRemove.Add($inline) } } foreach ($tsRun in $toRemove) { $inlines.Remove($tsRun) } }.GetNewClosure()) # Resize handler $consoleTextBox.Add_SizeChanged({ param($sender, $eventArgs) if (!$wrapCheckbox.IsChecked) { $minWidth = [Math]::Max($maxLineWidth.Value + 50, $consoleTextBox.ActualWidth) if ($consoleDocument.PageWidth -lt $minWidth) { $consoleDocument.PageWidth = $minWidth } } }.GetNewClosure()) # Create context menu $consoleContextMenu = [System.Windows.Controls.ContextMenu]::new() # Scoped Separator style $sepStyle = [System.Windows.Style]::new([System.Windows.Controls.Separator]) $sepStyle.Setters.Add([System.Windows.Setter]::new( [System.Windows.FrameworkElement]::MarginProperty, [System.Windows.Thickness]::new(0, 4, 0, 4) )) $sepStyle.Setters.Add([System.Windows.Setter]::new( [System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch )) $sepTemplate = [System.Windows.Controls.ControlTemplate]::new([System.Windows.Controls.Separator]) $borderFactory = [System.Windows.FrameworkElementFactory]::new([System.Windows.Controls.Border]) $borderFactory.SetValue([System.Windows.FrameworkElement]::HeightProperty, [double]1) $borderFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch) $borderFactory.SetValue([System.Windows.FrameworkElement]::SnapsToDevicePixelsProperty, $true) $borderFactory.SetResourceReference([System.Windows.Controls.Border]::BackgroundProperty, 'BorderBrush') $sepTemplate.VisualTree = $borderFactory $sepStyle.Setters.Add([System.Windows.Setter]::new( [System.Windows.Controls.Control]::TemplateProperty, $sepTemplate )) $consoleContextMenu.Resources.Add([System.Windows.Controls.Separator], $sepStyle) # Menu items $copyMenuItem = [System.Windows.Controls.MenuItem]@{ Header = 'Copy' Command = [System.Windows.Input.ApplicationCommands]::Copy } $selectAllMenuItem = [System.Windows.Controls.MenuItem]@{ Header = 'Select All' Command = [System.Windows.Input.ApplicationCommands]::SelectAll } $menuSep = [System.Windows.Controls.Separator]::new() $menuSep.Style = $sepStyle [void]$consoleContextMenu.Items.Add($copyMenuItem) [void]$consoleContextMenu.Items.Add($menuSep) [void]$consoleContextMenu.Items.Add($selectAllMenuItem) $consoleTextBox.ContextMenu = $consoleContextMenu # Wire up clear button $clearConsoleButton.Add_Click({ $consoleParagraph.Inlines.Clear() $maxLineWidth.Value = 0 $consoleDocument.PageWidth = $consoleTextBox.ActualWidth if ($findState) { $findState.SearchTerm = '' $findState.Matches.Clear() $findState.Index = -1 $findState.Label.Text = '' $findState.Label.Visibility = 'Hidden' } }.GetNewClosure()) # Wire up copy all button $copyAllButton.Add_Click({ $textRange = [System.Windows.Documents.TextRange]::new($consoleDocument.ContentStart, $consoleDocument.ContentEnd) $text = $textRange.Text if (![string]::IsNullOrWhiteSpace($text)) { [System.Windows.Clipboard]::SetText($text) $copyAllButton.Content = 'Copied!' $originalBg = $copyAllButton.Background $copyAllButton.Background = ConvertTo-UiBrush $Colors.Accent $timer = [System.Windows.Threading.DispatcherTimer]::new() $timer.Interval = [TimeSpan]::FromMilliseconds(1500) $timer.Tag = @{ Btn = $copyAllButton; Bg = $originalBg } $timer.Add_Tick({ $this.Tag.Btn.Background = $this.Tag.Bg; $this.Tag.Btn.Content = 'Copy All'; $this.Stop() }) $timer.Start() } }.GetNewClosure()) # Wire up save button $saveButton.Add_Click({ $textRange = [System.Windows.Documents.TextRange]::new($consoleDocument.ContentStart, $consoleDocument.ContentEnd) $text = $textRange.Text if (![string]::IsNullOrWhiteSpace($text)) { $dialog = [Microsoft.Win32.SaveFileDialog]@{ Filter = 'Text files (*.txt)|*.txt|Log files (*.log)|*.log|All files (*.*)|*.*' DefaultExt = '.txt' FileName = 'output.txt' Title = 'Save Console Output' OverwritePrompt = $true } if ($dialog.ShowDialog()) { [System.IO.File]::WriteAllText($dialog.FileName, $text) $saveButton.Content = 'Saved!' $originalBg = $saveButton.Background $saveButton.Background = ConvertTo-UiBrush $Colors.Accent $timer = [System.Windows.Threading.DispatcherTimer]::new() $timer.Interval = [TimeSpan]::FromMilliseconds(1500) $timer.Tag = @{ Btn = $saveButton; Bg = $originalBg } $timer.Add_Tick({ $this.Tag.Btn.Background = $this.Tag.Bg; $this.Tag.Btn.Content = 'Save'; $this.Stop() }) $timer.Start() } } }.GetNewClosure()) # Set tab content $consoleTab.Content = $consoleContainer # Track whether we're at the start of a line (for timestamp prefixing) $atLineStart = [ref]$true # Build state bag for appendConsoleText $appendState = @{ Paragraph = $consoleParagraph TextBox = $consoleTextBox Document = $consoleDocument WrapCheckbox = $wrapCheckbox AutoScrollCheckbox = $autoScrollCheckbox TimestampsCheckbox = $timestampsCheckbox AtLineStart = $atLineStart FindState = $findState MaxLineWidth = $maxLineWidth HighlightRunMatches = $highlightRunMatches } # Return all references needed by caller return @{ Tab = $consoleTab Container = $consoleContainer TextBox = $consoleTextBox Document = $consoleDocument Paragraph = $consoleParagraph AutoScrollCheckbox = $autoScrollCheckbox WrapCheckbox = $wrapCheckbox PinToTopCheckbox = $pinToTopCheckbox TimestampsCheckbox = $timestampsCheckbox FontSizeSlider = $fontSizeSlider ClearButton = $clearConsoleButton CopyAllButton = $copyAllButton SaveButton = $saveButton FindState = $findState ConsoleColorMap = $consoleColorMap RawColorMap = $rawColorMap AppendConsoleText = $appendConsoleText AppendState = $appendState HighlightRunMatches = $highlightRunMatches } } |