ui/code-view-box.psm1

using module .\progress-bar.psm1
using module ..\models\ast-model.psm1
using namespace System.Management.Automation.Language

Class CodeViewBox {
    [object]$mainForm # can't use type [MainForm] due to circular dependency
    [System.Windows.Forms.Control]$container
    [System.Windows.Forms.RichTextBox]$instance
    [AstModel]$astModel
    [Ast]$selectedAst
    [Ast]$selectedAstSecondary
    [string]$currentText
    [bool]$suppressTextChanged
    
    CodeViewBox([object]$mainForm, [System.Windows.Forms.Control]$container) {
        $this.mainForm = $mainForm
        $this.container = $container
        $this.instance = $this.Init()
    }    

    [System.Windows.Forms.RichTextBox]Init() {
        $label = [System.Windows.Forms.Label]::new()
        $label.Name = "lblCodeViewBox"
        $label.Text = "Code View"
        $label.Top = 20
        $label.Left = 2
        $label.Height = 20
        $label.Width=60
        $this.container.Controls.Add($label)

        $textBox = [System.Windows.Forms.RichTextBox]::new()
        $textBox.Name = "txtCodeViewBox"
        $textBox.Top = $label.Bottom
        $textBox.Left = 2
        $textBox.Height = $this.container.ClientSize.Height - $label.Bottom - 10
        $textBox.Width = $this.container.ClientSize.Width - 12
        $textBox.Multiline = $true          
        $textBox.WordWrap = $true
        $textBox.Font = [System.Drawing.Font]::new("Courier New", 12)
        $textBox.ScrollBars = "Both";
        $textBox.WordWrap = $false;
        $textBox.Anchor = "Top, Bottom, Left, Right"
        $textBox.Tag = $this
        $this.container.Controls.Add($textBox)

        $btnLoadScript = [System.Windows.Forms.Button]::new()
        $btnLoadScript.Text = "Load Script"
        $btnLoadScript.Width = 80
        $btnLoadScript.Height = 25
        $btnLoadScript.Top = 10
        $btnLoadScript.Left = $this.container.ClientSize.Width - $btnLoadScript.Width - 10
        $btnLoadScript.Anchor = "Top, Right"
        $btnLoadScript.Tag = $this
        $btnLoadScript.Add_Click({
                param($s, $e)
                $self = $s.Tag
                $self.mainForm.openScript()
            })
        $this.container.Controls.Add($btnLoadScript)

        $menu = New-Object System.Windows.Forms.ContextMenuStrip

        $findInAstItem = $menu.Items.Add("Find in AST Tree View (ctrl+click)")
        $findInAstItem.Add_Click({ 
                param($s, $e)
                # sender is a ToolStripMenuItem; get its ContextMenuStrip (owner)
                $cms = $s.GetCurrentParent()
                $rtb = $cms.SourceControl
                $charIndex = $rtb.SelectionStart
                $rtb.Tag.onCharIndexSelected($charIndex)
            })

        # Навешиваем меню
        $textBox.ContextMenuStrip = $menu

        $textBox.add_TextChanged({
                param($s, $e)
                $self = $s.Tag
                if ($self.suppressTextChanged) { return }
                $self.currentText = $s.Text
            })

        $textBox.Add_Leave({
                param($s, $e)
                $self = $s.Tag
                if ($self.currentText -ne $self.astModel.script) {
                    if ($self.currentText) {
                        $result = [System.Windows.Forms.MessageBox]::Show("Script text has changed. Recreate AST tree or cancel changes?",
                            "Confirm",
                            [System.Windows.Forms.MessageBoxButtons]::OKCancel,
                            [System.Windows.Forms.MessageBoxIcon]::Question
                        )

                        if ($result -eq [System.Windows.Forms.DialogResult]::OK) { $self.mainForm.onCodeChanged($self.currentText) }
                        else { $self.instance.Text = $self.astModel.script }
                    }
                    return
                }
            })

        $textBox.Add_GotFocus({
                param($s, $e)
                $rtb = $s.Tag.instance
                $selStart = $rtb.SelectionStart
                $selLength = $rtb.SelectionLength

                # Highlight reset (set background for all text)
                $rtb.SelectAll()
                $rtb.SelectionColor = [System.Drawing.Color]::Black
                $rtb.SelectionBackColor = [System.Drawing.Color]::White

                # Return cursor position
                $rtb.Select($selStart, $selLength)
            })

        $textBox.Add_MouseDown({
                param($s, $e)

                $self = $s.Tag
                $ctrl = $self.mainForm.ctrlPressed
                if ($e.Button -eq [System.Windows.Forms.MouseButtons]::Left -and $ctrl) {
                    $charIndex = $s.GetCharIndexFromPosition($e.Location)
                    $self.onCharIndexSelected($charIndex + $self.mainForm.filteredOffset)
                }

                if ($e.Button -eq [System.Windows.Forms.MouseButtons]::Right) {
                    $charIndex = $s.GetCharIndexFromPosition($e.Location) 
                    if ($charIndex -ge 0 -and $charIndex -lt $s.TextLength -and $s.SelectionLength -eq 0) { $s.Select($charIndex + $self.mainForm.filteredOffset, 0) }
                }
            })


        return $textBox
    }

    [void]setAstModel([AstModel]$astModel, [ProgressBar]$pb) {
        $this.suppressTextChanged = $true
        $this.astModel = $astModel
        $this.instance.Text = $astModel.script
        $this.currentText = $astModel.script
        $this.suppressTextChanged = $false
    }

    [void]onAstNodeSelected([Ast]$ast, [int]$index, [bool]$keepScrollPos) {
        $this.selectedAst = $ast
        $this.selectedAstSecondary = $null
        $this.highlightText(-not $keepScrollPos)
    }

    [void]onParameterSelected([object]$obj, [Ast]$ast) {
        if ($ast) { $this.selectedAstSecondary = $ast }
        else { $this.selectedAstSecondary = $null }
        $this.highlightText($true)
    }

    [void]highlightText($scrollToCaret = $true) {
        $rtb = $this.instance

        $scrollPos = $this.GetScrollPos()
        $this.DisableRedraw()

        # Colors
        <# Green
        $primaryColor = [System.Drawing.Color]::FromArgb(0, 100, 0) # primary
        $secondaryColor = [System.Drawing.Color]::FromArgb(1, 214, 65) # secondary
        $mixedColor = [System.Drawing.Color]::FromArgb(0, 172, 52) # overlap #>

        
        # Blue
        $primaryColor = [System.Drawing.Color]::FromArgb(0, 0, 150)       # primary
        $secondaryColor = [System.Drawing.Color]::FromArgb(35, 150, 230)    # secondary
        $mixedColor = [System.Drawing.Color]::FromArgb(20, 95, 210)     # overlap

        # Reset previous highlighting
        $rtb.SelectAll()
        $rtb.SelectionBackColor = [System.Drawing.Color]::White
        $rtb.SelectionColor = [System.Drawing.Color]::Black
        $rtb.DeselectAll()

        if ($null -eq $this.selectedAst) { return }

        # Primary range (must exist)
        [int]$primaryStart = $this.selectedAst.Extent.StartOffset - $this.mainForm.filteredOffset
        [int]$primaryEnd = $this.selectedAst.Extent.EndOffset - $this.mainForm.filteredOffset

        # Secondary range
        [int]$secondaryStart = 0
        [int]$secondaryEnd = 0
        if ($this.selectedAstSecondary) {
            $secondaryStart = [int]$this.selectedAstSecondary.Extent.StartOffset - $this.mainForm.filteredOffset
            $secondaryEnd = [int]$this.selectedAstSecondary.Extent.EndOffset - $this.mainForm.filteredOffset
        }

        # Only primary highlight and exit
        if (-not $this.selectedAstSecondary) {
            $this.SelectAndColor($primaryStart, $primaryEnd - $primaryStart, $primaryColor, [System.Drawing.Color]::White)
            if ($scrollToCaret) { $this.ScrollToCaret() }
            $rtb.DeselectAll()

            if (-not $scrollToCaret) { $this.RestoreScrollPos($scrollPos) }

            $this.EnableRedraw()
            return
        }

        # Compute overlap (safe: secondaryStart/End are initialized above)
        #[int]$minStart = [Math]::Min($primaryStart, $secondaryStart)
        [int]$overlapStart = [Math]::Max($primaryStart, $secondaryStart)
        [int]$overlapEnd = [Math]::Min($primaryEnd, $secondaryEnd)
        [bool]$hasOverlap = $overlapEnd -gt $overlapStart

        # Paint order: primary -> secondary -> overlap
        $this.SelectAndColor($primaryStart, $primaryEnd - $primaryStart, $primaryColor, [System.Drawing.Color]::White)
        $this.SelectAndColor($secondaryStart, $secondaryEnd - $secondaryStart, $secondaryColor, [System.Drawing.Color]::White)
        if ($hasOverlap) { $this.SelectAndColor($overlapStart, $overlapEnd - $overlapStart, $mixedColor, [System.Drawing.Color]::White) }
        
        $rtb.select($primaryStart, 0) # move caret to the start of the first highlight
        if ($scrollToCaret) { $this.ScrollToCaret() }
        else { $this.RestoreScrollPos($scrollPos) }

        $this.EnableRedraw()
    }

    [void]selectAndColor([int]$start, [int]$length, [System.Drawing.Color]$backColor, [System.Drawing.Color]$foreColor) {
        $textLen = $this.instance.TextLength
        if ($textLen -le 0) { return }

        $start = [Math]::Max(0, [Math]::Min($start, $textLen))
        $length = [Math]::Max(0, [Math]::Min($length, $textLen - $start))
        if ($length -le 0) { return }

        $this.instance.Select($start, $length)
        $this.instance.SelectionBackColor = $backColor
        $this.instance.SelectionColor = $foreColor
    } 

    [hashtable]GetScrollPos() {
        $wmUser = 0x400
        $emGetScrollPos = $wmUser + 221
        # Allocate 8 bytes for POINT structure (x, y)
        $ptr = [System.Runtime.InteropServices.Marshal]::AllocHGlobal(8)
        [void][Win32]::SendMessage($this.instance.Handle, $emGetScrollPos, 0, $ptr)

        # read 2 Int32 from memory
        $x = [System.Runtime.InteropServices.Marshal]::ReadInt32($ptr, 0)
        $y = [System.Runtime.InteropServices.Marshal]::ReadInt32($ptr, 4)

        [System.Runtime.InteropServices.Marshal]::FreeHGlobal($ptr)
        return @{X = $x; Y = $y }
    }

    [void]RestoreScrollPos([hashtable]$scrollPos) {
        $wmUser = 0x400
        $emSetScrollPos = $wmUser + 222
        # Allocate 8 bytes for POINT structure (x, y)
        $ptr = [System.Runtime.InteropServices.Marshal]::AllocHGlobal(8)

        # write 2 Int32 to memory
        [System.Runtime.InteropServices.Marshal]::WriteInt32($ptr, 0, $scrollPos.X)
        [System.Runtime.InteropServices.Marshal]::WriteInt32($ptr, 4, $scrollPos.Y)

        [void][Win32]::SendMessage($this.instance.Handle, $emSetScrollPos, 0, $ptr)
        [System.Runtime.InteropServices.Marshal]::FreeHGlobal($ptr)
    }
   
    [void]DisableRedraw() {
        $wmSetRedraw = 0xB
        [Win32]::SendMessage($this.instance.Handle, $wmSetRedraw, $false, 0)
    }
    [void]EnableRedraw() {
        $wmSetRedraw = 0xB
        [Win32]::SendMessage($this.instance.Handle, $wmSetRedraw, $true, 0)
        $this.instance.Refresh()
    }

    [void]ScrollToCaret() {
        # Get caret position in pixels relative to the RichTextBox control
        $pt = $this.instance.GetPositionFromCharIndex($this.instance.SelectionStart)

        # Visible area size
        $clientW = $this.instance.ClientSize.Width
        $clientH = $this.instance.ClientSize.Height

        $visibleY = ($pt.Y -ge 0 -and $pt.Y -lt $clientH)
        $visibleX = ($pt.X -ge 0 -and $pt.X -lt $clientW)

        if ($visibleX -and $visibleY) { return }
        $this.instance.ScrollToCaret()
    }
  
    [void]onCharIndexSelected([int]$charIndex) {
        $this.mainForm.onCharIndexSelected($charIndex)
    }

}