ui/code-view-box.psm1

using module .\progress-bar.psm1
using module ..\models\ast-model.psm1
using module .\search-panel.psm1
using module .\code-status-bar.psm1
using module ..\utils\debounce.psm1
using namespace System.Management.Automation.Language

$global:MyCounter = 0
Class CodeViewBox {
    # Main form instance
    [object]$mainForm # can't use type [MainForm] due to circular dependency
    # Parent container instance
    [System.Windows.Forms.Control]$container
    # RichTextBox instance
    [System.Windows.Forms.RichTextBox]$instance
    # Search panel instance
    [SearchPanel]$searchPanel
    # Code status bar instance
    [CodeStatusBar]$codeStatusBar
    # Debounce class instance for CodeStatusBar
    [Debounce]$statusDebounce
    # Current Ast model
    [AstModel]$astModel
    # Current selected Ast node
    [Ast]$selectedAst
    # Current selected secondary Ast node
    [Ast]$selectedAstSecondary
    # Current found block (substring) info {text, start, end}
    [hashtable]$foundBlock
    # Current entered text
    [string]$currentText
    # Flag to suppress TextChanged event
    [bool]$suppressTextChanged
    # Flag to suppress SelectionChanged event
    [bool]$suppressSelectionChanged
    # Flag to indicate if CodeViewBox is focused
    [bool]$isFocused
    
    CodeViewBox([object]$mainForm, [System.Windows.Forms.Control]$container) {
        $this.mainForm = $mainForm
        $this.container = $container
        $this.statusDebounce = [Debounce]::new(100)
        $this.instance = $this.Init()
        $this.searchPanel = [SearchPanel]::new($this, $this.instance)
    }    

    [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 - 25
        $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)

        $this.codeStatusBar = [CodeStatusBar]::new($this.mainForm, $this.container, $this)

        $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 = [System.Windows.Forms.ContextMenuStrip]::new()

        $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
                $charPos = $rtb.SelectionStart
                $rtb.Tag.selectAstNodeByCharPos($charPos)
            })

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

        $this.initEvents($textBox)

        return $textBox
    }

    [void]initEvents([System.Windows.Forms.RichTextBox]$textBox) {
        $textBox.add_SelectionChanged({
                param($s, $e)
                if ($s.Tag.suppressSelectionChanged) { return }
                $s.Tag.statusDebounce.run({ param($self, [int]$pos) $self.showCurrentToken($pos) }, @($s.Tag, $s.SelectionStart))
            })

        $textBox.add_TextChanged({
                param($s, $e)
                $self = $s.Tag
                if ($self.currentText -eq $s.Text) { return }
                $self.currentText = $s.Text
                $self.searchPanel.invokeDebouncedSearch("Current", $true)
            })

        $textBox.Add_Leave({
                param($s, $e)
                $self = $s.Tag
                $self.isFocused = $false
                $self.highlightText($null)
                if (-not $self.isCodeChanged()) { return }
                
                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 }
                }
                else {
                    $self.mainForm.onCodeChanged($self.currentText)
                }

            })

        $textBox.Add_GotFocus({
                param($s, $e)
                $self = $s.Tag
                $self.isFocused = $true
                $self.highlightText($null)
            })

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

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

                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) }
                }
            })

        $textBox.Add_KeyDown({
                param($s, $e)
                $self = $s.Tag

                if ($e.Control -and $e.KeyCode -eq [System.Windows.Forms.Keys]::F) {
                    $selText = $self.getSelectedText().trim()
                    if ($self.searchPanel.isVisible() -and -not $selText) { return }
                    $self.searchPanel.show($true, $selText)
                }
                elseif ($e.KeyCode -eq [System.Windows.Forms.Keys]::Escape) {
                    if (-not $self.searchPanel.isVisible()) { return }
                    $self.searchPanel.show($false)
                }
            })
    }

    [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
        $scrollBlockType = $null
        if (-not $keepScrollPos) { $scrollBlockType = "PrimaryAst" }
        $this.highlightText($scrollBlockType)
    }

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

    # Returns array of hashtable {Start, End, Color, BgColor}, calculated from intersecting selectedAst and selectedAstSecondary extents positions
    [hashtable[]]getAstHighlightedBlocks() {
        if (-not $this.selectedAst -and -not $this.selectedAstSecondary) { return @() }

        $primaryColor = [System.Drawing.Color]::FromArgb(0, 120, 215)      # primary
        $secondaryColor = [System.Drawing.Color]::FromArgb(61, 160, 236)   # secondary
        $overlapColor = [System.Drawing.Color]::FromArgb(0, 99, 174)       # overlap

        # Primary range (must exist)
        [int]$primaryStart = 0
        [int]$primaryEnd = 0
        if ($this.selectedAst) {
            [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
        if ($this.selectedAst -and -not $this.selectedAstSecondary) {
            return @(@{ Type = "PrimaryAst"; Start = $primaryStart; End = $primaryEnd; Color = [System.Drawing.Color]::White; BgColor = $primaryColor })
        }
        
        # Only secondary highlight
        if ( $this.selectedAstSecondary -and -not $this.selectedAst) {
            return @(@{ Type = "SecondaryAst"; Start = $secondaryStart; End = $secondaryEnd; Color = [System.Drawing.Color]::White; BgColor = $secondaryColor })
        }

        
        # Check overlap
        if ($primaryEnd -lt $secondaryStart -or $secondaryEnd -lt $primaryStart) {
            # No overlap
            return @(
                @{ Type = "PrimaryAst"; Start = $primaryStart; End = $primaryEnd; Color = [System.Drawing.Color]::White; BgColor = $primaryColor },
                @{ Type = "SecondaryAst"; Start = $secondaryStart; End = $secondaryEnd; Color = [System.Drawing.Color]::White; BgColor = $secondaryColor }
            )
        }

        # Case: equal ranges -> return one block
        if ($primaryStart -eq $secondaryStart -and $primaryEnd -eq $secondaryEnd) {
            return @(@{ Type = "OverlapAst"; Start = $primaryStart; End = $primaryEnd; Color = [System.Drawing.Color]::White; BgColor = $overlapColor })
        }

        # Now we know: ranges overlap but are not equal
        [int]$overlapStart = [Math]::Max($primaryStart, $secondaryStart)
        [int]$overlapEnd = [Math]::Min($primaryEnd, $secondaryEnd)

        $result = @()

        # ----- Left block -----
        [int]$leftStart = [Math]::Min($primaryStart, $secondaryStart)
        [int]$leftEnd = $overlapStart

        if ($leftStart -lt $leftEnd) {
            $leftType = if ($primaryStart -lt $secondaryStart) { "PrimaryAst" } else { "SecondaryAst" }
            $leftColor = if ($leftType -eq "PrimaryAst") { $primaryColor } else { $secondaryColor }

            $result += @{ Type = $leftType; Start = $leftStart; End = $leftEnd; Color = [System.Drawing.Color]::White; BgColor = $leftColor }
        }

        # ----- Overlap block -----
        if ($overlapEnd -gt $overlapStart) {
            $result += @{ Type = "OverlapAst"; Start = $overlapStart; End = $overlapEnd; Color = [System.Drawing.Color]::White; BgColor = $overlapColor }
        }

        # ----- Right block -----
        [int]$rightStart = $overlapEnd
        [int]$rightEnd = [Math]::Max($primaryEnd, $secondaryEnd)

        if ($rightStart -lt $rightEnd) {
            $rightType = if ($primaryEnd -gt $secondaryEnd) { "PrimaryAst" } else { "SecondaryAst" }
            $rightColor = if ($rightType -eq "PrimaryAst") { $primaryColor } else { $secondaryColor }
            $result += @{Type = $rightType; Start = $rightStart; End = $rightEnd; Color = [System.Drawing.Color]::White; BgColor = $rightColor }
        }

        return $result
    }

    # Merge Ast positions blocks with found block. Found block has higher priority
    [hashtable[]] MergeFoundBlock([hashtable[]] $astBlocks, [hashtable] $foundBlock) {

        # If no found block -> return original
        if (-not $foundBlock) { return $astBlocks }

        [int]$foundStart = $foundBlock.Start
        [int]$foundEnd = $foundBlock.End

        $result = @()

        foreach ($block in $astBlocks) {

            [int]$bStart = $block.Start
            [int]$bEnd = $block.End

            # ---- No overlap ----
            if ($bEnd -le $foundStart -or $foundEnd -le $bStart) {
                # keep block unchanged
                $result += $block
                continue
            }

            # ---- Found fully covers block and skip it ----
            if ($foundStart -le $bStart -and $foundEnd -ge $bEnd) { continue }

            # ---- Partial overlap: split into left and right parts ----

            # Left part (block before found)
            if ($bStart -lt $foundStart) { $result += @{Type = $block.Type; Start = $bStart; End = $foundStart; Color = $block.Color; BgColor = $block.BgColor; } }

            # Right part (block after found)
            if ($bEnd -gt $foundEnd) { $result += @{Type = $block.Type; Start = $foundEnd; End = $bEnd; Color = $block.Color; BgColor = $block.BgColor } }
        }

        # Add found block itself (priority)
        $result += $foundBlock

        # Sort final output
        return $result | Sort-Object Start
    }

    # Highlight text
    [void]highlightText([string]$scrollToBlock = $null) {
        $this.suppressSelectionChanged = $true
        $rtb = $this.instance

        $currentPos = $rtb.SelectionStart
        $scrollPos = $this.GetScrollPos()
        $this.DisableRedraw()

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

        $blocks = @()
        if (-not $this.isFocused) { $blocks = $this.getAstHighlightedBlocks() }
        if ($this.foundBlock) { $blocks = $this.MergeFoundBlock($blocks, $this.foundBlock) }
        
        foreach ($block in $blocks) {
            if ($scrollToBlock -and $block.Type -eq $scrollToBlock) { $currentPos = $block.Start }
            $rtb.Select($block.Start, $block.End - $block.Start)
            $rtb.SelectionBackColor = $block.BgColor
            $rtb.SelectionColor = $block.Color
        }
        $rtb.DeselectAll()
        $rtb.Select($currentPos, 0)
        if ($scrollToBlock) { $this.ScrollToCaret() }
        else { $this.SetScrollPos($scrollPos) }

        $this.EnableRedraw()
        $this.suppressSelectionChanged = $false
    }

    # Get current scroll position
    [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 }
    }

    # Set scroll position
    [void]SetScrollPos([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)
    }
   
    # Disable richTextBox redraw
    [void]DisableRedraw() {
        $this.instance.SuspendLayout()
        $wmSetRedraw = 0xB
        [Win32]::SendMessage($this.instance.Handle, $wmSetRedraw, $false, 0)

    }

    # Enable richTextBox redraw
    [void]EnableRedraw() {
        $wmSetRedraw = 0xB
        [Win32]::SendMessage($this.instance.Handle, $wmSetRedraw, $true, 0)
        $this.instance.ResumeLayout()
        $this.instance.Refresh()
    }

    # Scroll richTextBox to caret. Keep scroll if caret is visible
    [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()
    }
  
    # Find AST node by char position
    [void]selectAstNodeByCharPos([int]$charPos) {
        $this.mainForm.selectAstNodeByCharPos($charPos)
    }

    [void]onSearch([string]$text, [string]$direction) { 
        $this.onSearch($text, $direction, $false, -1)
    }

    # Search substring
    [void]onSearch([string]$text, [string]$direction, [bool]$keepScrollPos, [int]$searchStartPos) {
        if (-not $text) { 
            $this.foundBlock = $null
            $this.highlightText($null)
            return 
        }

        $full = $this.instance.Text
        if (-not $full) { return }
   
        $curr = $this.instance.SelectionStart
        if ($direction -eq "Current" -and $this.foundBlock) { 
            $curr =[Math]::Min($curr,  $this.foundBlock.Start) 
            $direction=""
        }
        if ($searchStartPos -ge 0) { $curr = $searchStartPos }
        $index = -1

        switch ($direction) {

            '' {
                # first search from beginning
                $index = $full.IndexOf($text, $curr, [StringComparison]::'InvariantCultureIgnoreCase')
                if ($index -lt 0) { $index = $full.IndexOf($text, 0, [StringComparison]::'InvariantCultureIgnoreCase') }
            }

            'next' {
                $start = $curr + 1
                if ($start -ge $full.Length) { $start = 0 }
                $index = $full.IndexOf($text, $start, [StringComparison]::'InvariantCultureIgnoreCase')
                if ($index -lt 0) { $index = $full.IndexOf($text, 0, [StringComparison]::'InvariantCultureIgnoreCase') }
            }

            'prev' {
                $start = $curr - 1
                if ($start -lt 0) { $start = $full.Length - 1 }
                $index = $full.LastIndexOf($text, $start, [StringComparison]::'InvariantCultureIgnoreCase')
                if ($index -lt 0) { $index = $full.LastIndexOf($text, $full.Length - 1, [StringComparison]::'InvariantCultureIgnoreCase') }
            }
        }

        if ($index -ge 0) { 
            $this.foundBlock = @{ Type = "Found"; Start = $index; End = $index + $text.Length; Color = [System.Drawing.Color]::Black; BgColor = [System.Drawing.Color]::FromArgb(255, 245, 170) }
        }
        else {
            $this.foundBlock = $null
        }

        $scrollToBlock = $null
        if (-not $keepScrollPos) { $scrollToBlock = "Found" }
        $this.highlightText($scrollToBlock)
    }

    # Get selected text in richTextBox
    [string]getSelectedText() {
        $res = $this.instance.SelectedText
        if (-not $res) { $res = "" }
        return $res
    }

    # Show current token in status bar
    [void]showCurrentToken([int]$charIndex) {
        if ($this.isCodeChanged()) { 
            $this.codeStatusBar.update("Code changed, Ast needs to be rebuilt")
            return 
        }

        $token = $this.astModel.GetTokenByCharIndex($charIndex)
        $tokenName = ""
        $tokenFlags = ""
        if ($token) {
            $tokenName = " Token: [$($token.Kind)]"
            if ($token.TokenFlags) { 
                $tokenFlags = $token.TokenFlags -join ", " 
                $tokenFlags = " Flags: [$tokenFlags]"
            }
        }

        $this.codeStatusBar.update("Position: $charIndex$tokenName$tokenFlags")
    }

    # Returns true if text changed and Ast tree needs to be rebuilt
    [bool]isCodeChanged() {
        return $this.currentText -ne $this.astModel.script
    }

}