public/output/Out-TextEditor.ps1

function Out-TextEditor {
    <#
    .SYNOPSIS
        Opens a themed text editor window.
    .DESCRIPTION
        Displays text in a themed editor window. Find and copy are built in, and save is optional.
        Accepts input via parameter or pipeline.
    .PARAMETER InputObject
        Text to display. Accepts string array from pipeline.
    .PARAMETER InitialText
        Initial text content (alias for backward compatibility).
    .PARAMETER TitleText
        Window title.
    .PARAMETER Theme
        Color theme to use. Defaults to Light.
    .PARAMETER ReadOnly
        When specified, the text editor opens in read-only mode. The Save button is hidden
        and the Cancel button becomes "Close" with accent styling.
    .PARAMETER NoWordWrap
        When specified, disables word wrap (shows horizontal scrollbar for long lines).
        Word wrap is enabled by default.
    .PARAMETER SpellCheck
        When specified, enables spell checking with red underlines for misspelled words.
        Spell check is disabled by default.
    .PARAMETER Width
        Window width in pixels.
    .PARAMETER Height
        Window height in pixels.
    .PARAMETER FontFamily
        Font face for the editor. Defaults to Consolas.
    .PARAMETER FontSize
        Font size in points.
    .PARAMETER IconFont
        Icon font to use for the editor's chrome (header, find, save, etc): Inherit (default -
        keep whatever Set-PsUiIconFont last established), Auto (re-detect), SegoeMDL2, or
        SegoeFluentIcons. Honored only when launched standalone - when hosted inside a parent
        New-UiWindow the parent's font wins. Restored to the previous active font on close.
    .PARAMETER NoIconFontFallback
        Pin to the chosen icon font with no WPF fallback chain. Honored only when standalone.
        On Win10 with only MDL2 installed this changes nothing visually (no secondary to fall
        back to). The remaining effect is tighter IntelliSense for -Icon names.
    .EXAMPLE
        Out-TextEditor -InitialText "Hello World"
    .EXAMPLE
        Get-Content C:\file.txt | Out-TextEditor -Theme Dark
    .EXAMPLE
        "Line 1", "Line 2", "Line 3" | Out-TextEditor
    .EXAMPLE
        Out-TextEditor -InitialText 'Modern' -IconFont SegoeFluentIcons
        # Render chrome icons in Fluent for this window only.
    #>

    [CmdletBinding()]
    param(
        [Parameter(ValueFromPipeline = $true)]
        [string[]]$InputObject,

        [string]$InitialText = '',
        [Alias('Title')]
        [string]$TitleText = 'Text Editor',
        [ArgumentCompleter({ [PsUi.ThemeEngine]::GetAvailableThemes() })]
        [string]$Theme = 'Light',
        [ValidateRange(300, 2000)]
        [int]$Width = 800,
        [ValidateRange(200, 1500)]
        [int]$Height = 600,
        [string]$FontFamily = 'Consolas',
        [ValidateRange(8, 24)]
        [int]$FontSize = 12,
        # Default 'Inherit' (rather than leaving unbound) so $IconFont always satisfies the ValidateSet. Any .GetNewClosure() would otherwise carry an unbound "" through the attribute and explode.
        [ValidateSet('Inherit', 'Auto', 'SegoeMDL2', 'SegoeFluentIcons')]
        [string]$IconFont = 'Inherit',
        [switch]$NoIconFontFallback,
        [switch]$ReadOnly,
        [switch]$NoWordWrap,
        [switch]$SpellCheck
    )

    begin {
        # ShowDialog requires the UI thread - block async button actions from calling this
        if ([PsUi.AsyncExecutor]::CurrentExecutor) {
            Write-Error 'Out-TextEditor cannot be called from an async button action (ShowDialog requires the UI thread). Use -NoAsync on your button, or call this function outside the DSL.'
            return
        }

        Write-Debug "Starting with Title='$TitleText', Theme='$Theme', Font='$FontFamily' $FontSize pt"
        $collectedLines = [System.Collections.Generic.List[string]]::new()
    }

    process {
        if ($InputObject) {
            foreach ($line in $InputObject) {
                [void]$collectedLines.Add($line)
            }
        }
    }

    end {
    # Combine pipeline input with InitialText parameter
    $finalText = if ($collectedLines.Count -gt 0) {
        Write-Debug "Collected $($collectedLines.Count) lines from pipeline"
        $collectedLines -join "`n"
    }
    elseif ($InitialText) {
        Write-Debug "Using InitialText parameter ($($InitialText.Length) chars)"
        $InitialText
    }
    else {
        Write-Debug "No initial content"
        ''
    }

    $isStandalone = !(Test-Path variable:__WPFThemeColors)
    Write-Debug "Context: isStandalone=$isStandalone, textLength=$($finalText.Length)"

    # RowContextMenu actions invoke without __WPFThemeColors in scope. Fall back to ActiveTheme, or the default 'Light' below flips the parent window via Initialize-UITheme.
    if (!$PSBoundParameters.ContainsKey('Theme')) {
        $active = [PsUi.ModuleContext]::ActiveTheme
        if (![string]::IsNullOrWhiteSpace($active)) { $Theme = $active }
    }

    # If standalone, null out the calling script's session pointers around ShowDialog. The editor has no PsUi session of its own (no New-UiButton, no helpers). Without this clear, the parent's CurrentSessionId rides in by thread, Add_Closed disposes it, and the parent window's controls and variables go with it.
    $priorSessionId = $null
    $priorGlobalId  = $null
    if ($isStandalone) {
        $priorSessionId = [PsUi.SessionManager]::CurrentSessionId
        $priorGlobalId  = if (Test-Path variable:Global:__PsUiSessionId) { $Global:__PsUiSessionId } else { $null }

        [PsUi.SessionManager]::ClearCurrentSession()
        Remove-Variable -Name __PsUiSessionId -Scope Global -ErrorAction SilentlyContinue

        # If standalone, create the Application on this (ShowDialog) thread before Initialize-UITheme. ThemeEngine no longer creates it, and a cross thread App loses content theming. Does nothing if one exists. Then initialize full theme resources with -Theme (resolved above).
        [void][PsUi.ThemeEngine]::EnsureApplication()
        $colors = Initialize-UITheme -Theme $Theme
    }
    else {
        # Child window: parent's theme colors are already in __WPFThemeColors.
        $colors = Get-Variable -Name __WPFThemeColors -ValueOnly -ErrorAction SilentlyContinue
    }

    # Push -IconFont override when standalone. Returns $null when inside a parent (parent's font wins) or when no override params were supplied. Restore runs after ShowDialog on the success path. The trap below covers throws between here and there.
    $overrideParams = @{
        IsStandalone       = $isStandalone
        BoundParameters    = $PSBoundParameters
        IconFont           = $IconFont
        NoIconFontFallback = [bool]$NoIconFontFallback
    }
    $iconFontSnap = Push-UiIconFontOverride @overrideParams

    # A throw between the push and the post ShowDialog restore would leak the override and the session swap. Function scope trap restores both, then break rethrows.
    trap {
        if ($iconFontSnap) {
            [PsUi.ModuleContext]::RestoreIconFontState($iconFontSnap)
            $iconFontSnap = $null
        }
        if ($isStandalone) {
            if ($priorSessionId -and $priorSessionId -ne [Guid]::Empty) {
                [PsUi.SessionManager]::SetCurrentSession($priorSessionId)
            }
            if ($null -ne $priorGlobalId) {
                $Global:__PsUiSessionId = $priorGlobalId
            }
            else {
                Remove-Variable -Name __PsUiSessionId -Scope Global -ErrorAction SilentlyContinue
            }
        }
        break
    }

    # Ultimate fallback theme colors
    if (!$colors) {
        $colors = @{
            WindowBg         = '#FFFFFF'
            WindowFg         = '#202020'
            ControlBg        = '#F3F3F3'
            ControlFg        = '#202020'
            HeaderBackground = '#F0F0F0'
            HeaderForeground = '#202020'
            Border           = '#D1D1D1'
            Accent           = '#0078D4'
            SecondaryText    = '#666666'
            ButtonBg         = '#FFFFFF'
            ButtonFg         = '#202020'
            ButtonHover      = '#EFEFEF'
        }
    }

    $window = [System.Windows.Window]@{
        Title                 = $TitleText
        Width                 = $Width
        Height                = $Height
        MinWidth              = 300
        MinHeight             = 200
        WindowStartupLocation = 'CenterScreen'
        FontFamily            = [System.Windows.Media.FontFamily]::new('Segoe UI')
        ResizeMode            = 'CanResizeWithGrip'
    }

    # Bind to parent for consistent window management
    $null = Set-WindowOwner -Window $window
    $window.SetResourceReference([System.Windows.Window]::BackgroundProperty, 'WindowBackgroundBrush')
    $window.SetResourceReference([System.Windows.Window]::ForegroundProperty, 'ControlForegroundBrush')

    Set-UIResources -Window $window -Colors $colors

    $appId = "PsUi.TextEditor." + [Guid]::NewGuid().ToString("N").Substring(0, 8)
    [PsUi.WindowManager]::SetWindowAppId($window, $appId)

    $editorWindowIcon = $null
    try {
        $editorWindowIcon = New-WindowIcon -Colors $colors
        if ($editorWindowIcon) {
            $window.Icon = $editorWindowIcon
        }
    }
    catch {
        Write-Verbose "Failed to create window icon: $_"
    }

    $overlayIcon = $null
    try {
        $docGlyph = [PsUi.ModuleContext]::GetIcon('Document')
        $overlayIcon = New-TaskbarOverlayIcon -GlyphChar $docGlyph -Color $colors.Accent
        # Store glyph in resources for theme updates
        $window.Resources['OverlayGlyph'] = $docGlyph
    }
    catch { Write-Debug "Taskbar overlay failed: $_" }

    $capturedEditorWindow = $window
    $capturedEditorIcon   = $editorWindowIcon
    $capturedOverlay      = $overlayIcon

    $window.Add_Loaded({
        if ($capturedEditorIcon) {
            [PsUi.WindowManager]::SetTaskbarIcon($capturedEditorWindow, $capturedEditorIcon)
        }
        if ($capturedOverlay) {
            [PsUi.WindowManager]::SetTaskbarOverlay($capturedEditorWindow, $capturedOverlay, 'Document')
        }
    }.GetNewClosure())

    $mainPanel = [System.Windows.Controls.DockPanel]@{
        LastChildFill = $true
    }
    $window.Content = $mainPanel

    # Header bar
    $headerBorder = [System.Windows.Controls.Border]@{
        Padding = [System.Windows.Thickness]::new(16, 12, 16, 12)
        Tag = 'HeaderBorder'
    }
    $headerBorder.SetResourceReference([System.Windows.Controls.Border]::BackgroundProperty, 'HeaderBackgroundBrush')
    [System.Windows.Controls.DockPanel]::SetDock($headerBorder, 'Top')

    $headerGrid = [System.Windows.Controls.Grid]::new()
    $col1 = [System.Windows.Controls.ColumnDefinition]@{
        Width = [System.Windows.GridLength]::new(1, [System.Windows.GridUnitType]::Star)
    }
    $col2 = [System.Windows.Controls.ColumnDefinition]@{
        Width = [System.Windows.GridLength]::Auto
    }
    [void]$headerGrid.ColumnDefinitions.Add($col1)
    [void]$headerGrid.ColumnDefinitions.Add($col2)

    $headerStack = [System.Windows.Controls.StackPanel]@{
        Orientation = 'Horizontal'
    }
    [System.Windows.Controls.Grid]::SetColumn($headerStack, 0)

    $headerIcon = [System.Windows.Controls.TextBlock]@{
        Text              = [PsUi.ModuleContext]::GetIcon('Edit')
        FontFamily        = [PsUi.ModuleContext]::ActiveIconFontFamily
        FontSize          = 24
        VerticalAlignment = 'Center'
        Width             = 32
        TextAlignment     = 'Center'
        Margin            = [System.Windows.Thickness]::new(0, 0, 12, 0)
        Tag               = 'AccentIcon'
    }
    # SetResourceReference is a method, not a property, so it must be called after object creation
    $headerIcon.SetResourceReference([System.Windows.Controls.TextBlock]::ForegroundProperty, 'AccentBrush')
    [void]$headerStack.Children.Add($headerIcon)

    $headerTitle = [System.Windows.Controls.TextBlock]@{
        Text              = $TitleText
        FontSize          = 18
        FontWeight        = [System.Windows.FontWeights]::SemiBold
        VerticalAlignment = 'Center'
        Tag               = 'HeaderText'
    }
    $headerTitle.SetResourceReference([System.Windows.Controls.TextBlock]::ForegroundProperty, 'HeaderForegroundBrush')
    [void]$headerStack.Children.Add($headerTitle)

    [void]$headerGrid.Children.Add($headerStack)

        # Add theme button to header - only when running standalone
        if ($isStandalone) {
            $themeButtonData = New-ThemePopupButton -Container $window -CurrentTheme $Theme
            [System.Windows.Controls.Grid]::SetColumn($themeButtonData.Button, 1)
            [void]$headerGrid.Children.Add($themeButtonData.Button)
        }

    $headerBorder.Child = $headerGrid
    [void]$mainPanel.Children.Add($headerBorder)

    # Content area
    $contentPanel = [System.Windows.Controls.DockPanel]@{
        Margin        = [System.Windows.Thickness]::new(12)
        LastChildFill = $true
    }
    [void]$mainPanel.Children.Add($contentPanel)

    # Toolbar at top
    $toolbar = [System.Windows.Controls.StackPanel]@{
        Orientation = 'Horizontal'
        Margin      = [System.Windows.Thickness]::new(0, 0, 0, 8)
        Height      = 32
    }
    [System.Windows.Controls.DockPanel]::SetDock($toolbar, 'Top')
    [void]$contentPanel.Children.Add($toolbar)

    # Copy All button with icon
    $copyAllBtn = [System.Windows.Controls.Button]::new()
    $copyAllPanel = [System.Windows.Controls.StackPanel]::new()
    $copyAllPanel.Orientation = 'Horizontal'
    $copyAllIcon = [System.Windows.Controls.TextBlock]::new()
    $copyAllIcon.Text = [PsUi.ModuleContext]::GetIcon('Copy')
    $copyAllIcon.FontFamily = [PsUi.ModuleContext]::ActiveIconFontFamily
    $copyAllIcon.FontSize = 12
    $copyAllIcon.Margin = [System.Windows.Thickness]::new(0, 0, 4, 0)
    $copyAllIcon.VerticalAlignment = 'Center'
    [void]$copyAllPanel.Children.Add($copyAllIcon)
    $copyAllText = [System.Windows.Controls.TextBlock]::new()
    $copyAllText.Text = 'Copy All'
    $copyAllText.VerticalAlignment = 'Center'
    [void]$copyAllPanel.Children.Add($copyAllText)
    $copyAllBtn.Content = $copyAllPanel
    $copyAllBtn.Height = 28
    $copyAllBtn.Padding = [System.Windows.Thickness]::new(8, 4, 8, 4)
    $copyAllBtn.Margin = [System.Windows.Thickness]::new(0, 0, 4, 0)
    $copyAllBtn.VerticalAlignment = 'Center'
    $copyAllBtn.ToolTip = 'Copy all text to clipboard (Ctrl+Shift+C)'
    Set-ButtonStyle -Button $copyAllBtn
    [void]$toolbar.Children.Add($copyAllBtn)

    # Clear button with icon
    $clearBtn = [System.Windows.Controls.Button]::new()
    $clearPanel = [System.Windows.Controls.StackPanel]::new()
    $clearPanel.Orientation = 'Horizontal'
    $clearIcon = [System.Windows.Controls.TextBlock]::new()
    $clearIcon.Text = [PsUi.ModuleContext]::GetIcon('Delete')
    $clearIcon.FontFamily = [PsUi.ModuleContext]::ActiveIconFontFamily
    $clearIcon.FontSize = 12
    $clearIcon.Margin = [System.Windows.Thickness]::new(0, 0, 4, 0)
    $clearIcon.VerticalAlignment = 'Center'
    [void]$clearPanel.Children.Add($clearIcon)
    $clearText = [System.Windows.Controls.TextBlock]::new()
    $clearText.Text = 'Clear'
    $clearText.VerticalAlignment = 'Center'
    [void]$clearPanel.Children.Add($clearText)
    $clearBtn.Content = $clearPanel
    $clearBtn.Height = 28
    $clearBtn.Padding = [System.Windows.Thickness]::new(8, 4, 8, 4)
    $clearBtn.Margin = [System.Windows.Thickness]::new(0, 0, 8, 0)
    $clearBtn.VerticalAlignment = 'Center'
    $clearBtn.ToolTip = 'Clear all text'
    Set-ButtonStyle -Button $clearBtn
    [void]$toolbar.Children.Add($clearBtn)

    $wrapCheck = [System.Windows.Controls.CheckBox]::new()
    $wrapCheck.Content = 'Word Wrap'
    $wrapCheck.IsChecked = !$NoWordWrap
    $wrapCheck.VerticalAlignment = 'Center'
    $wrapCheck.Margin = [System.Windows.Thickness]::new(0, 0, 8, 0)
    $wrapCheck.ToolTip = 'Toggle word wrapping'
    Set-CheckBoxStyle -CheckBox $wrapCheck
    $wrapCheck.SetResourceReference([System.Windows.Controls.CheckBox]::ForegroundProperty, 'ControlForegroundBrush')
    [void]$toolbar.Children.Add($wrapCheck)

    $spellCheckBox = [System.Windows.Controls.CheckBox]::new()
    $spellCheckBox.Content = 'Spell Check'
    $spellCheckBox.IsChecked = $SpellCheck
    $spellCheckBox.VerticalAlignment = 'Center'
    $spellCheckBox.Margin = [System.Windows.Thickness]::new(0, 0, 8, 0)
    $spellCheckBox.ToolTip = 'Toggle spell checking'
    Set-CheckBoxStyle -CheckBox $spellCheckBox
    $spellCheckBox.SetResourceReference([System.Windows.Controls.CheckBox]::ForegroundProperty, 'ControlForegroundBrush')
    [void]$toolbar.Children.Add($spellCheckBox)

    # Font size control with label beneath slider
    $fontSizePanel = [System.Windows.Controls.StackPanel]::new()
    $fontSizePanel.Orientation = 'Vertical'
    $fontSizePanel.VerticalAlignment = 'Center'
    $fontSizePanel.Margin = [System.Windows.Thickness]::new(8, 0, 0, 0)

    $fontSizeSlider = [System.Windows.Controls.Slider]::new()
    $fontSizeSlider.Minimum = 8
    $fontSizeSlider.Maximum = 32
    $fontSizeSlider.Value = $FontSize
    $fontSizeSlider.Width = 100
    $fontSizeSlider.TickFrequency = 1
    $fontSizeSlider.IsSnapToTickEnabled = $true
    $fontSizeSlider.ToolTip = 'Adjust font size (8-32pt). Ctrl+Scroll to change. Double-click to reset.'
    $fontSizeSlider.Tag = $FontSize
    Set-SliderStyle -Slider $fontSizeSlider
    [void]$fontSizePanel.Children.Add($fontSizeSlider)

    $fontLabel = [System.Windows.Controls.TextBlock]::new()
    $fontLabel.Text = 'Font Size'
    $fontLabel.HorizontalAlignment = 'Center'
    $fontLabel.FontSize = 10
    $fontLabel.SetResourceReference([System.Windows.Controls.TextBlock]::ForegroundProperty, 'ControlForegroundBrush')
    [void]$fontSizePanel.Children.Add($fontLabel)

    [void]$toolbar.Children.Add($fontSizePanel)

    # Status and search bar at bottom
    $statusBar = [System.Windows.Controls.Border]::new()
    $statusBar.SetResourceReference([System.Windows.Controls.Border]::BackgroundProperty, 'HeaderBackgroundBrush')
    $statusBar.SetResourceReference([System.Windows.Controls.Border]::BorderBrushProperty, 'BorderBrush')
    $statusBar.BorderThickness = [System.Windows.Thickness]::new(0, 1, 0, 0)
    $statusBar.Padding = [System.Windows.Thickness]::new(8, 4, 8, 4)
    $statusBar.Height = 32
    $statusBar.Tag = 'HeaderBorder'
    [System.Windows.Controls.DockPanel]::SetDock($statusBar, 'Bottom')

    $statusGrid = [System.Windows.Controls.Grid]::new()
    $statusCol1 = [System.Windows.Controls.ColumnDefinition]::new()
    $statusCol1.Width = [System.Windows.GridLength]::Auto
    $statusCol2 = [System.Windows.Controls.ColumnDefinition]::new()
    $statusCol2.Width = [System.Windows.GridLength]::new(1, [System.Windows.GridUnitType]::Star)
    $statusCol3 = [System.Windows.Controls.ColumnDefinition]::new()
    $statusCol3.Width = [System.Windows.GridLength]::Auto
    [void]$statusGrid.ColumnDefinitions.Add($statusCol1)
    [void]$statusGrid.ColumnDefinitions.Add($statusCol2)
    [void]$statusGrid.ColumnDefinitions.Add($statusCol3)

    $statusText = [System.Windows.Controls.TextBlock]::new()
    $statusText.Text = 'Line: 1 Col: 1'
    $statusText.FontSize = 11
    $statusText.VerticalAlignment = 'Center'
    $statusText.SetResourceReference([System.Windows.Controls.TextBlock]::ForegroundProperty, 'HeaderForegroundBrush')
    $statusText.Tag = 'HeaderText'
    [System.Windows.Controls.Grid]::SetColumn($statusText, 0)
    [void]$statusGrid.Children.Add($statusText)

    $findPanel = [System.Windows.Controls.StackPanel]::new()
    $findPanel.Orientation = 'Horizontal'
    $findPanel.HorizontalAlignment = 'Right'
    $findPanel.VerticalAlignment = 'Center'
    [System.Windows.Controls.Grid]::SetColumn($findPanel, 2)

    $findLabel = [System.Windows.Controls.TextBlock]::new()
    $findLabel.Text = 'Find:'
    $findLabel.FontSize = 11
    $findLabel.VerticalAlignment = 'Center'
    $findLabel.Margin = [System.Windows.Thickness]::new(0, 0, 4, 0)
    $findLabel.SetResourceReference([System.Windows.Controls.TextBlock]::ForegroundProperty, 'HeaderForegroundBrush')
    $findLabel.Tag = 'HeaderText'
    [void]$findPanel.Children.Add($findLabel)

    # Wrap findBox in Grid to overlay clear button
    $findBoxContainer = [System.Windows.Controls.Grid]::new()
    $findBoxContainer.Width = 200
    $findBoxContainer.Height = 20
    $findBoxContainer.VerticalAlignment = 'Center'

    $findBox = [System.Windows.Controls.TextBox]::new()
    $findBox.Height = 20
    $findBox.FontSize = 11
    $findBox.VerticalAlignment = 'Center'
    $findBox.Padding = [System.Windows.Thickness]::new(2, 0, 18, 0)
    $findBox.ToolTip = 'Search for text (case-insensitive by default)'
    Set-TextBoxStyle -TextBox $findBox
    $findBox.BorderThickness = [System.Windows.Thickness]::new(1)
    [void]$findBoxContainer.Children.Add($findBox)

    $findClearBtn = [System.Windows.Controls.Button]::new()
    $findClearBtn.Content = [PsUi.ModuleContext]::GetIcon('Cancel')
    $findClearBtn.FontFamily = [PsUi.ModuleContext]::ActiveIconFontFamily
    $findClearBtn.FontSize = 9
    $findClearBtn.Width = 14
    $findClearBtn.Height = 14
    $findClearBtn.Padding = [System.Windows.Thickness]::new(0)
    $findClearBtn.Margin = [System.Windows.Thickness]::new(0, 0, 3, 0)
    $findClearBtn.HorizontalAlignment = 'Right'
    $findClearBtn.VerticalAlignment = 'Center'
    $findClearBtn.Background = [System.Windows.Media.Brushes]::Transparent
    $findClearBtn.BorderThickness = [System.Windows.Thickness]::new(0)
    $findClearBtn.SetResourceReference([System.Windows.Controls.Button]::ForegroundProperty, 'SecondaryTextBrush')
    $findClearBtn.Cursor = [System.Windows.Input.Cursors]::Hand
    $findClearBtn.Visibility = 'Collapsed'
    $findClearBtn.ToolTip = 'Clear'
    $findClearBtn.Tag = $findBox
    $findClearBtn.Add_Click({ $this.Tag.Text = ''; $this.Tag.Focus() }.GetNewClosure())
    [void]$findBoxContainer.Children.Add($findClearBtn)

    $findBox.Tag = @{ ClearButton = $findClearBtn }
    [void]$findPanel.Children.Add($findBoxContainer)

    $findPrevBtn = [System.Windows.Controls.Button]::new()
    $findPrevBtn.Width = 24
    $findPrevBtn.Height = 20
    $findPrevBtn.Padding = [System.Windows.Thickness]::new(0)
    $findPrevBtn.Margin = [System.Windows.Thickness]::new(2, 0, 0, 0)
    $findPrevBtn.VerticalAlignment = 'Center'
    $findPrevBtn.ToolTip = 'Find previous occurrence (Shift+F3)'
    $findPrevIcon = [System.Windows.Controls.TextBlock]::new()
    $findPrevIcon.Text = [PsUi.ModuleContext]::GetIcon('ArrowLeft')
    $findPrevIcon.FontFamily = [PsUi.ModuleContext]::ActiveIconFontFamily
    $findPrevIcon.FontSize = 12
    $findPrevIcon.HorizontalAlignment = 'Center'
    $findPrevIcon.VerticalAlignment = 'Center'
    $findPrevBtn.Content = $findPrevIcon
    Set-ButtonStyle -Button $findPrevBtn
    [void]$findPanel.Children.Add($findPrevBtn)

    $findNextBtn = [System.Windows.Controls.Button]::new()
    $findNextBtn.Width = 24
    $findNextBtn.Height = 20
    $findNextBtn.Padding = [System.Windows.Thickness]::new(0)
    $findNextBtn.Margin = [System.Windows.Thickness]::new(2, 0, 0, 0)
    $findNextBtn.VerticalAlignment = 'Center'
    $findNextBtn.ToolTip = 'Find next occurrence (F3)'
    $findNextIcon = [System.Windows.Controls.TextBlock]::new()
    $findNextIcon.Text = [PsUi.ModuleContext]::GetIcon('ArrowRight')
    $findNextIcon.FontFamily = [PsUi.ModuleContext]::ActiveIconFontFamily
    $findNextIcon.FontSize = 12
    $findNextIcon.HorizontalAlignment = 'Center'
    $findNextIcon.VerticalAlignment = 'Center'
    $findNextBtn.Content = $findNextIcon
    Set-ButtonStyle -Button $findNextBtn
    [void]$findPanel.Children.Add($findNextBtn)

    $matchCaseCheck = [System.Windows.Controls.CheckBox]::new()
    $matchCaseCheck.Content = 'Aa'
    $matchCaseCheck.ToolTip = 'Enable case-sensitive search'
    $matchCaseCheck.FontSize = 10
    $matchCaseCheck.VerticalAlignment = 'Center'
    $matchCaseCheck.VerticalContentAlignment = 'Center'
    $matchCaseCheck.Margin = [System.Windows.Thickness]::new(6, 0, 0, 0)
    Set-CheckBoxStyle -CheckBox $matchCaseCheck
    $matchCaseCheck.SetResourceReference([System.Windows.Controls.CheckBox]::ForegroundProperty, 'HeaderForegroundBrush')
    [void]$findPanel.Children.Add($matchCaseCheck)

    # Find counter label
    # Width sized for "99 found" or "9 of 99" - prevents layout shifts when text changes
    $FIND_COUNTER_WIDTH = 60
    $findCountLabel = [System.Windows.Controls.TextBlock]::new()
    $findCountLabel.Text = ''
    $findCountLabel.FontSize = 10
    $findCountLabel.VerticalAlignment = 'Center'
    $findCountLabel.Margin = [System.Windows.Thickness]::new(8, 1, 0, 0)
    $findCountLabel.SetResourceReference([System.Windows.Controls.TextBlock]::ForegroundProperty, 'HeaderForegroundBrush')
    $findCountLabel.Tag = 'HeaderText'
    $findCountLabel.ToolTip = 'Shows current match / total matches'
    $findCountLabel.Width = $FIND_COUNTER_WIDTH
    $findCountLabel.TextAlignment = 'Left'
    # Start hidden to reserve space
    $findCountLabel.Visibility = [System.Windows.Visibility]::Hidden
    [void]$findPanel.Children.Add($findCountLabel)

    [void]$statusGrid.Children.Add($findPanel)
    $statusBar.Child = $statusGrid
    [void]$contentPanel.Children.Add($statusBar)

    # Button panel
    $buttonPanel = [System.Windows.Controls.StackPanel]::new()
    $buttonPanel.Orientation = 'Horizontal'
    $buttonPanel.HorizontalAlignment = 'Right'
    $buttonPanel.Margin = [System.Windows.Thickness]::new(0, 8, 0, 0)
    [System.Windows.Controls.DockPanel]::SetDock($buttonPanel, 'Bottom')
    [void]$contentPanel.Children.Add($buttonPanel)

    $saveBtn = [System.Windows.Controls.Button]::new()
    $saveBtn.Content = 'Save'
    $saveBtn.Width = 90
    $saveBtn.Height = 32
    $saveBtn.Margin = [System.Windows.Thickness]::new(0, 0, 8, 8)
    Set-ButtonStyle -Button $saveBtn -Accent
    if ($ReadOnly) { $saveBtn.Visibility = 'Collapsed' }
    [void]$buttonPanel.Children.Add($saveBtn)

    $cancelBtn = [System.Windows.Controls.Button]::new()
    $cancelBtn.Content = if ($ReadOnly) { 'Close' } else { 'Cancel' }
    $cancelBtn.Width = 90
    $cancelBtn.Height = 32
    $cancelBtn.Margin = [System.Windows.Thickness]::new(0, 0, 0, 8)
    Set-ButtonStyle -Button $cancelBtn
    if ($ReadOnly) { Set-ButtonStyle -Button $cancelBtn -Accent }
    [void]$buttonPanel.Children.Add($cancelBtn)

    $scrollViewer = [System.Windows.Controls.ScrollViewer]::new()
    $scrollViewer.VerticalScrollBarVisibility = 'Auto'

    # Default to wrap on unless NoWordWrap specified
    $scrollViewer.HorizontalScrollBarVisibility = if ($NoWordWrap) { 'Auto' } else { 'Disabled' }
    [void]$contentPanel.Children.Add($scrollViewer)

    # Multi-line editor textbox - don't use Set-TextBoxStyle since it applies a single-line template
    # Instead, manually apply theme colors for proper multi-line behavior
    $textBox = [System.Windows.Controls.TextBox]::new()
    $textBox.Text = $finalText
    $textBox.AcceptsReturn = $true
    $textBox.AcceptsTab = $true
    $textBox.TextWrapping = if ($NoWordWrap) { 'NoWrap' } else { 'Wrap' }
    $textBox.VerticalScrollBarVisibility = 'Hidden'
    $textBox.HorizontalScrollBarVisibility = 'Hidden'
    $textBox.IsInactiveSelectionHighlightEnabled = $true
    $textBox.VerticalContentAlignment = 'Top'
    $textBox.BorderThickness = [System.Windows.Thickness]::new(0.5)
    $textBox.FontFamily = [System.Windows.Media.FontFamily]::new($FontFamily)
    $textBox.FontSize = $FontSize
    $textBox.Padding = [System.Windows.Thickness]::new(8)
    $textBox.IsReadOnly = $ReadOnly

    # Enable spell check only if explicitly requested
    [System.Windows.Controls.SpellCheck]::SetIsEnabled($textBox, $SpellCheck)

    # Base context menu - spell suggestions get appended dynamically on open
    $baseContextMenu = New-TextBoxContextMenu -ReadOnly:$ReadOnly
    $textBox.ContextMenu = $baseContextMenu

    # Add spell check suggestions when context menu opens
    $textBox.Add_ContextMenuOpening({
        param($sender, $eventArgs)

        # Get the textbox and its context menu
        $tb = $sender
        $menu = $tb.ContextMenu

        # Remove any previous spell check items (they have Tag = 'SpellCheck')
        $itemsToRemove = @($menu.Items | Where-Object { $_.Tag -eq 'SpellCheck' })
        foreach ($item in $itemsToRemove) {
            [void]$menu.Items.Remove($item)
        }

        # Spell check suggestions go at the top of the context menu
        if ([System.Windows.Controls.SpellCheck]::GetIsEnabled($tb)) {
            # Get the character index at the mouse position (not caret position)
            $mousePos = [System.Windows.Input.Mouse]::GetPosition($tb)
            $charIndex = $tb.GetCharacterIndexFromPoint($mousePos, $true)

            if ($charIndex -ge 0) {
                $spellingError = $tb.GetSpellingError($charIndex)

                if ($spellingError) {
                    $suggestions = $spellingError.Suggestions
                    $insertIndex = 0

                    # Add spelling suggestions
                    if ($suggestions) {
                        foreach ($suggestion in $suggestions) {
                            $suggestionItem = [System.Windows.Controls.MenuItem]::new()
                            $suggestionItem.Header = $suggestion
                            $suggestionItem.FontWeight = [System.Windows.FontWeights]::SemiBold
                            $suggestionItem.Tag = 'SpellCheck'

                            # Capture values for the click handler
                            $capturedSuggestion = $suggestion
                            $capturedError = $spellingError
                            $capturedTextBox = $tb
                            $suggestionItem.Add_Click({
                                $capturedTextBox.BeginChange()
                                $capturedError.Correct($capturedSuggestion)
                                $capturedTextBox.EndChange()
                            }.GetNewClosure())

                            [void]$menu.Items.Insert($insertIndex, $suggestionItem)
                            $insertIndex++
                        }

                        # Add separator after spell check suggestions
                        $spellSeparator = [System.Windows.Controls.Separator]::new()
                        $spellSeparator.Tag = 'SpellCheck'
                        [void]$menu.Items.Insert($insertIndex, $spellSeparator)
                    }
                }
            }
        }
    }.GetNewClosure())

    # Apply theme colors via resource references
    $textBox.SetResourceReference([System.Windows.Controls.TextBox]::BackgroundProperty, 'ControlBackgroundBrush')
    $textBox.SetResourceReference([System.Windows.Controls.TextBox]::ForegroundProperty, 'ControlForegroundBrush')
    $textBox.SetResourceReference([System.Windows.Controls.TextBox]::BorderBrushProperty, 'BorderBrush')
    $textBox.SetResourceReference([System.Windows.Controls.TextBox]::CaretBrushProperty, 'ControlForegroundBrush')
    $textBox.SetResourceReference([System.Windows.Controls.Primitives.TextBoxBase]::SelectionBrushProperty, 'AccentBrush')
    $textBox.SelectionOpacity = 0.4

    $scrollViewer.Content = $textBox

    # Autofocus textbox when not in readonly mode
    if (!$ReadOnly) {
        $window.Add_ContentRendered({ $textBox.Focus() }.GetNewClosure())
    }

    $textBox.Add_SelectionChanged({
        try {
            $text = $textBox.Text
            $caretIndex = $textBox.CaretIndex
            $lineNumber = 1
            $colNumber = 1
            if ($caretIndex -gt 0 -and $text.Length -gt 0) {
                $beforeCaret = $text.Substring(0, [Math]::Min($caretIndex, $text.Length))
                $lineNumber = ($beforeCaret.ToCharArray() | Where-Object { $_ -eq "`n" }).Count + 1
                $lastNewLine = $beforeCaret.LastIndexOf("`n")
                $colNumber = if ($lastNewLine -ge 0) { $caretIndex - $lastNewLine } else { $caretIndex + 1 }
            }
            $statusText.Text = "Line: $lineNumber Col: $colNumber Length: $($text.Length)"
        }
        catch {
            Write-Verbose "Failed to update status text: $_"
        }
    }.GetNewClosure())

    $wrapCheck.Add_Checked({
        $textBox.TextWrapping = 'Wrap'
        $scrollViewer.HorizontalScrollBarVisibility = 'Disabled'
    }.GetNewClosure())

    $wrapCheck.Add_Unchecked({
        $textBox.TextWrapping = 'NoWrap'
        $scrollViewer.HorizontalScrollBarVisibility = 'Auto'
    }.GetNewClosure())

    $spellCheckBox.Add_Checked({
        [System.Windows.Controls.SpellCheck]::SetIsEnabled($textBox, $true)
    }.GetNewClosure())

    $spellCheckBox.Add_Unchecked({
        [System.Windows.Controls.SpellCheck]::SetIsEnabled($textBox, $false)
    }.GetNewClosure())

    $fontSizeSlider.Add_ValueChanged({
        $textBox.FontSize = $fontSizeSlider.Value
    }.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 over textbox area to change font size
    $scrollViewer.Add_PreviewMouseWheel({
        param($sender, $wheelArgs)
        if ([System.Windows.Input.Keyboard]::Modifiers -eq 'Control') {
            $wheelArgs.Handled = $true
            $delta    = if ($wheelArgs.Delta -gt 0) { 1 } else { -1 }
            $newValue = $fontSizeSlider.Value + $delta

            if ($newValue -ge $fontSizeSlider.Minimum -and $newValue -le $fontSizeSlider.Maximum) {
                $fontSizeSlider.Value = $newValue
            }
        }
    }.GetNewClosure())

    $copyAllBtn.Add_Click({
        if ($textBox.Text.Length -gt 0) {
            [System.Windows.Clipboard]::SetText($textBox.Text)
            # Brief visual feedback - change text and flash accent color
            $copyAllText.Text = 'Copied!'
            $originalBg = $copyAllBtn.Background
            $copyAllBtn.Background = $window.TryFindResource('AccentBrush')

            # Use script-scoped variables for timer callback
            $script:_copyTimer = [System.Windows.Threading.DispatcherTimer]::new()
            $script:_copyTimer.Interval = [TimeSpan]::FromMilliseconds(2000)
            $script:_copyTimer.Tag = @{ Button = $copyAllBtn; Text = $copyAllText; OriginalBg = $originalBg }
            $script:_copyTimer.Add_Tick({
                param($sender, $eventArgs)
                $data = $sender.Tag
                $data.Button.Background = $data.OriginalBg
                $data.Text.Text = 'Copy All'
                $sender.Stop()
            })
            $script:_copyTimer.Start()
        }
    }.GetNewClosure())

    $clearBtn.Add_Click({
        if ($textBox.Text.Length -gt 0) {
            $result = Show-UiMessageDialog -Title 'Confirm Clear' -Message 'Clear all text? This cannot be undone.' -Buttons YesNo -Icon Question -ThemeColors $colors
            if ($result -eq 'Yes') {
                $textBox.Text = ''
                # Reset find state
                $findCountLabel.Visibility = [System.Windows.Visibility]::Hidden
                $findCountLabel.Text = ''
            }
        }
    }.GetNewClosure())

    # Text search with real-time highlighting and match counter
    # Use a hashtable to encapsulate state for this specific instance
    $findState = @{
        Matches = [System.Collections.Generic.List[int]]::new()
        CurrentIndex = -1
    }

    # Helper function to update selection with visual focus toggle
    # Forces WPF to render the selection by briefly focusing the textbox
    $updateSelectionWithFocus = {
        param(
            [int]$index,
            [int]$length
        )

        # Bail on invalid range
        if ($index -lt 0 -or $length -lt 0) {
            Write-Verbose "Invalid selection range: index=$index, length=$length"
            return
        }

        $textBox.SelectionStart = $index
        $textBox.SelectionLength = $length
        $textBox.Focus()  # Give focus to render selection
        $textBox.ScrollToLine($textBox.GetLineIndexFromCharacterIndex($index))
        $findBox.Focus()  # Immediately return focus to Find box
        # Selection remains visible due to IsInactiveSelectionHighlightEnabled
    }

    $updateFindMatches = {
        $searchText = $findBox.Text
        $findState.Matches = [System.Collections.Generic.List[int]]::new()
        $findState.CurrentIndex = -1
        $findCountLabel.Text = ''

        # Hide label when search is empty
        if ([string]::IsNullOrEmpty($searchText)) {
            $findCountLabel.Visibility = [System.Windows.Visibility]::Hidden
            return
        }

        $text = $textBox.Text
        $comparison = if ($matchCaseCheck.IsChecked) {
            [System.StringComparison]::Ordinal
        }
        else {
            [System.StringComparison]::OrdinalIgnoreCase
        }

        $index = 0
        while ($index -lt $text.Length) {
            $foundIndex = $text.IndexOf($searchText, $index, $comparison)
            if ($foundIndex -ge 0) {
                $findState.Matches.Add($foundIndex)
                $index = $foundIndex + 1
            }
            else {
                break
            }
        }

        if ($findState.Matches.Count -gt 0) {
            $findCountLabel.Text = "$($findState.Matches.Count) found"
            $findCountLabel.Visibility = [System.Windows.Visibility]::Visible
            $findState.CurrentIndex = 0

            # Update selection with visual focus toggle
            & $updateSelectionWithFocus $findState.Matches[0] $searchText.Length
        }
        else {
            $findCountLabel.Text = "0 found"
            $findCountLabel.Visibility = [System.Windows.Visibility]::Visible
        }
    }

    # Ctrl+Z/Ctrl+Y in findBox must not propagate through the focus toggle to the main editor.
    # The focus toggle in $updateSelectionWithFocus briefly gives focus to the textBox, and if the undo keystroke is still being processed, WPF routes it there mid undo - crash.
    $findBox.Add_PreviewKeyDown({
        param($sender, $eventArgs)
        $mod = [System.Windows.Input.Keyboard]::Modifiers
        if ($mod -eq 'Control' -and ($eventArgs.Key -eq 'Z' -or $eventArgs.Key -eq 'Y')) {
            try {
                if ($eventArgs.Key -eq 'Z') { $sender.Undo() }
                else { $sender.Redo() }
            }
            catch { <# Undo/Redo throws if an undo unit is open mid text change - not critical #> }
            $eventArgs.Handled = $true
        }
    })

    $findBox.Add_TextChanged({
        # Show/hide clear button
        $clearBtn = $findBox.Tag.ClearButton
        if ($clearBtn) {
            $clearBtn.Visibility = if ([string]::IsNullOrEmpty($findBox.Text)) { 'Collapsed' } else { 'Visible' }
        }
        & $updateFindMatches
    }.GetNewClosure())

    $matchCaseCheck.Add_Checked({
        & $updateFindMatches
    }.GetNewClosure())

    $matchCaseCheck.Add_Unchecked({
        & $updateFindMatches
    }.GetNewClosure())

    $findNextBtn.Add_Click({
        if ($findState.Matches.Count -gt 0) {
            $findState.CurrentIndex = ($findState.CurrentIndex + 1) % $findState.Matches.Count
            $searchText = $findBox.Text

            # Update selection with visual focus toggle
            & $updateSelectionWithFocus $findState.Matches[$findState.CurrentIndex] $searchText.Length

            $findCountLabel.Text = "$($findState.CurrentIndex + 1) of $($findState.Matches.Count)"
        }
    }.GetNewClosure())

    $findPrevBtn.Add_Click({
        if ($findState.Matches.Count -gt 0) {
            $findState.CurrentIndex = ($findState.CurrentIndex - 1)
            if ($findState.CurrentIndex -lt 0) {
                $findState.CurrentIndex = $findState.Matches.Count - 1
            }
            $searchText = $findBox.Text

            # Update selection with visual focus toggle
            & $updateSelectionWithFocus $findState.Matches[$findState.CurrentIndex] $searchText.Length

            $findCountLabel.Text = "$($findState.CurrentIndex + 1) of $($findState.Matches.Count)"
        }
    }.GetNewClosure())

    $cancelBtn.Add_Click({ $window.Tag = $null; $window.Close() }.GetNewClosure())
    $saveBtn.Add_Click({ $window.Tag = $textBox.Text; $window.Close() }.GetNewClosure())

    Initialize-UiWindowLoaded -Window $window -SetIcon

    # Clean up session on window close (only if this call created it, not wtihin an inherited session)
    $window.Add_Closed({
        # Stop the copy feedback timer if it's still pending - otherwise it fires once against the disposed window, touching controls that are already gone.
        if ($script:_copyTimer) {
            try { $script:_copyTimer.Stop() } catch { Write-Debug "Copy timer stop: $_" }
            $script:_copyTimer = $null
        }
        if ($isStandalone) {
            $sessionId = [PsUi.SessionManager]::CurrentSessionId
            if ($sessionId -ne [Guid]::Empty) {
                [PsUi.SessionManager]::DisposeSession($sessionId)
            }
        }
    }.GetNewClosure())

    [void]$window.ShowDialog()

    # Tail teardown, not a finally: off pipeline (called from a -NoAsync click) a finally NREs on exit. The function scope trap above mirrors this for a throw from ShowDialog.
    # Restore the icon font if an override was pushed. Nothing to restore though when $iconFontSnap is $null.
    [PsUi.ModuleContext]::RestoreIconFontState($iconFontSnap)
    $iconFontSnap = $null

    # Restore the calling script's session pointers. Add_Closed already disposed any editor session. CurrentSessionId is Empty by now.
    if ($isStandalone) {
        if ($priorSessionId -and $priorSessionId -ne [Guid]::Empty) { [PsUi.SessionManager]::SetCurrentSession($priorSessionId) }

        if ($null -ne $priorGlobalId) {  $Global:__PsUiSessionId = $priorGlobalId  }
        else { Remove-Variable -Name __PsUiSessionId -Scope Global -ErrorAction SilentlyContinue  }
    }

    return $window.Tag
    } # end block
}