Private/Console/Rendering.psm1

using namespace System
using namespace System.Collections.Generic
using namespace System.Collections
using namespace System.Text

using module ..\Enums.psm1
using module ..\Abstracts.psm1
using module .\Colors.psm1
using module .\Internal.psm1

class RenderableExtensions {
}

class RenderHookScope : IDisposable {
  [void] Dispose() { }
}


class Style {
  [Color]$Foreground
  [Color]$Background
  [Decoration]$Decoration

  static [Style] $Plain

  static Style() {
    [Style]::Plain = [Style]::new([Color]::Default, [Color]::Default, [Decoration]::None)
  }

  Style() {
    $this.Foreground = [Color]::Default
    $this.Background = [Color]::Default
    $this.Decoration = [Decoration]::None
  }

  Style([Color]$foreground) {
    $this.Foreground = ($null -ne $foreground) ? $foreground : [Color]::Default
    $this.Background = [Color]::Default
    $this.Decoration = [Decoration]::None
  }

  Style([Color]$foreground, [Color]$background, [Decoration]$decoration) {
    $this.Foreground = ($null -ne $foreground) ? $foreground : [Color]::Default
    $this.Background = ($null -ne $background) ? $background : [Color]::Default
    $this.Decoration = $decoration
  }

  static [Style] Parse([string]$text) {
    return [MarkupStyleParser]::ParseStyle($text)
  }

  static [bool] TryParse([string]$text, [ref]$result) {
    try {
      $result.Value = [Style]::Parse($text)
      return $true
    } catch {
      $result.Value = [Style]::Plain
      return $false
    }
  }

  [Style] Combine([Style]$other) {
    if ($null -eq $other) {
      return [Style]::new($this.Foreground, $this.Background, $this.Decoration)
    }

    $_foreground = $this.Foreground
    if ($null -ne $other.Foreground -and !$other.Foreground.IsDefault) {
      $_foreground = $other.Foreground
    }

    $_background = $this.Background
    if ($null -ne $other.Background -and !$other.Background.IsDefault) {
      $_background = $other.Background
    }

    return [Style]::new($_foreground, $_background, ($this.Decoration -bor $other.Decoration))
  }

  [string] ToMarkup() {
    $builder = [List[string]]::new()
    if ($this.Decoration -ne [Decoration]::None) {
      $builder.AddRange([MarkupStyleParser]::GetMarkupNames($this.Decoration))
    }
    if (!$this.Foreground.IsDefault) {
      $builder.Add($this.Foreground.ToMarkup())
    }
    if (!$this.Background.IsDefault) {
      if ($builder.Count -eq 0) { $builder.Add("default") }
      $builder.Add("on " + $this.Background.ToMarkup())
    }
    return [string]::Join(" ", $builder)
  }

  [string] ToString() {
    return $this.ToMarkup()
  }
}


class Segment {
  [string]$Text
  [Style]$Style
  [bool]$IsLineBreak
  [bool]$IsWhiteSpace
  [bool]$IsControlCode

  static [Segment] $LineBreak
  static [Segment] $Empty

  static Segment() {
    [Segment]::LineBreak = [Segment]::new("`n", [Style]::Plain, $true, $false)
    [Segment]::Empty = [Segment]::new([string]::Empty, [Style]::Plain, $false, $false)
  }

  Segment([string]$text) {
    [void]$this.Init($text, [Style]::Plain, $false, $false)
  }

  Segment([string]$text, [Style]$style) {
    [void]$this.Init($text, $style, $false, $false)
  }

  hidden [void] Init([string]$text, [Style]$style, [bool]$lineBreak, [bool]$control) {
    if ($null -eq $text) { throw [ArgumentNullException]::new("text") }
    $this.Text = $text
    $this.Style = ($null -ne $style) ? $style : [Style]::Plain
    $this.IsLineBreak = $lineBreak
    $this.IsWhiteSpace = [string]::IsNullOrWhiteSpace($text)
    $this.IsControlCode = $control
  }

  Segment([string]$text, [Style]$style, [bool]$lineBreak, [bool]$control) {
    [void]$this.Init($text, $style, $lineBreak, $control)
  }

  static [Segment] Padding([int]$size) {
    return [Segment]::new((' ' * $size))
  }

  static [Segment] Control([string]$control) {
    return [Segment]::new($control, [Style]::Plain, $false, $true)
  }

  [int] CellCount() {
    if ($this.IsControlCode) { return 0 }
    if ($this.Text -eq "`n") { return 1 }
    return [Cell]::GetCellLength($this.Text)
  }

  static [int] CellCount([IEnumerable]$segments) {
    if ($null -eq $segments) { return 0 }
    $sum = 0
    foreach ($segment in $segments) {
      $sum += $segment.CellCount()
    }
    return $sum
  }

  [ValueTuple[Segment, Segment]] Split([int]$offset) {
    $count = $this.CellCount()
    if ($offset -le 0) { return [ValueTuple[Segment, Segment]]::new($null, $this) }
    if ($offset -ge $count) { return [ValueTuple[Segment, Segment]]::new($this, $null) }

    # Simple split based on string length for now.
    # TODO: Improve this to handle cluster boundaries.
    $splitIndex = if ($offset -lt $this.Text.Length) { $offset } else { $this.Text.Length }
    $firstText = $this.Text.Substring(0, $splitIndex)
    $secondText = $this.Text.Substring($splitIndex)
    return [ValueTuple[Segment, Segment]]::new(
      [Segment]::new($firstText, $this.Style, $this.IsLineBreak, $this.IsControlCode),
      [Segment]::new($secondText, $this.Style, $this.IsLineBreak, $this.IsControlCode)
    )
  }

  static [List[Segment]] Truncate([IEnumerable]$segments, [int]$maxWidth) {
    $result = [List[Segment]]::new()
    $currentLength = 0
    foreach ($seg in $segments) {
      if ($null -eq $seg -or $seg -isnot [Segment]) { continue }
      $segLen = $seg.CellCount()
      if ($currentLength + $segLen -le $maxWidth) {
        $result.Add($seg)
        $currentLength += $segLen
      } else {
        $offset = $maxWidth - $currentLength
        if ($offset -gt 0) {
          $split = $seg.Split($offset)
          if ($null -ne $split.Item1) { $result.Add($split.Item1) }
        }
        break
      }
    }
    return $result
  }
  static [List[SegmentLine]] SplitLines([Segment[]]$segments, [int]$maxWidth) {
    return [Segment]::SplitLines((, $segments), $maxWidth)
  }
  static [List[SegmentLine]] SplitLines([IEnumerable]$segments, [int]$maxWidth) {
    # CRITICAL BUG FIX PRESERVATION (#4 Array Unrolling & ETS Methods Context Failures):
    # NEVER use [Segment[]]$segmentsArr = $segments.ForEach({ $_ })
    # This code may run natively inside threaded [System.Threading.Timer] callbacks (like Progress logic).
    # Background threads operate without the full PowerShell Extended Type System (ETS) properties mapped.
    # Therefore, .ForEach({...}) silently binds to the standard .NET List<T>.ForEach(Action<T>)
    # instead of the PS Intrinsic collection evaluator, causing the parsing engine to return VOID silently.
    
    # We must explicitly type-check and manually unroll 1-layer wrappers (created by the clumsy `(, $segments)` array overload)
    # natively without relying on PS implicit multidimensional coercion either!
    $list = [List[Segment]]::new()
    if ($null -ne $segments) {
      foreach ($s in $segments) {
        if ($null -eq $s) { continue }
        if ($s -is [Segment]) {
          $list.Add([Segment]$s)
        } elseif ($s -is [IEnumerable]) {
          foreach ($inner in $s) {
            if ($null -ne $inner -and $inner -is [Segment]) {
              $list.Add([Segment]$inner)
            }
          }
        }
      }
    }
    $lines = [List[SegmentLine]]::new()
    $currentLine = [SegmentLine]::new()

    # Reverse stack because we pop from top
    $list.Reverse()
    $stack = [Stack]::new($list)
    while ($stack.Count -gt 0) {
      $segment = $stack.Pop()
      $segmentLength = $segment.CellCount()
      $lineLength = $currentLine.CellCount()

      if ($lineLength + $segmentLength -gt $maxWidth) {
        $offset = $maxWidth - $lineLength
        if ($offset -gt 0) {
          $split = $segment.Split($offset)
          if ($null -ne $split.Item1) { $currentLine.Add($split.Item1) }
          $lines.Add($currentLine)
          $currentLine = [SegmentLine]::new()
          if ($null -ne $split.Item2) { $stack.Push($split.Item2) }
        } else {
          $lines.Add($currentLine)
          $currentLine = [SegmentLine]::new()
          $stack.Push($segment)
        }
        continue
      }

      if ($segment.Text.Contains("`n")) {
        $parts = $segment.Text.Split("`n")
        for ($i = 0; $i -lt $parts.Length; $i++) {
          if ($parts[$i].Length -gt 0) {
            $currentLine.Add([Segment]::new($parts[$i], $segment.Style))
          }
          if ($i -lt $parts.Length - 1) {
            $lines.Add($currentLine)
            $currentLine = [SegmentLine]::new()
          }
        }
      } else {
        $currentLine.Add($segment)
      }
    }

    if ($currentLine.Count() -gt 0) {
      $lines.Add($currentLine)
    }

    return $lines
  }

  [string] ToString() { return $this.Text }
}

class SegmentLine {
  [List[Segment]]$Segments
  SegmentLine() { $this.Segments = [List[Segment]]::new() }
  SegmentLine([IEnumerable[Segment]]$segments) { $this.Segments = [List[Segment]]::new($segments) }

  [void] Add([Segment]$segment) { $this.Segments.Add($segment) }
  [int] CellCount() { return [Segment]::CellCount($this.Segments) }
  [int] Count() { return $this.Segments.Count }

  [string] ToString() {
    $sb = [StringBuilder]::new()
    foreach ($s in $this.Segments) { [void]$sb.Append($s.Text) }
    return $sb.ToString()
  }
}

class MarkupSegmentInfo {
  [string]$Text
  [Style]$Style
  [string]$Link

  MarkupSegmentInfo([string]$text, [Style]$style, [string]$link) {
    $this.Text = $text
    $this.Style = ($null -ne $style) ? $style : [Style]::Plain
    $this.Link = $link
  }
}

class MarkupStyleState {
  [Style]$Style
  [string]$Link

  MarkupStyleState([Style]$style, [string]$link) {
    $this.Style = ($null -ne $style) ? $style : [Style]::Plain
    $this.Link = $link
  }
}

class MarkupStyleStateDelta {
  [Style]$Style
  [string]$Link

  MarkupStyleStateDelta([Style]$style, [string]$link) {
    $this.Style = ($null -ne $style) ? $style : [Style]::Plain
    $this.Link = $link
  }
}

class MarkupStyleParser {
  static [Nullable[Decoration]] GetDecoration([string]$name) {
    switch ($name.ToLowerInvariant()) {
      'none' { return [Decoration]::None }
      'bold' { return [Decoration]::Bold }
      'b' { return [Decoration]::Bold }
      'dim' { return [Decoration]::Dim }
      'italic' { return [Decoration]::Italic }
      'i' { return [Decoration]::Italic }
      'underline' { return [Decoration]::Underline }
      'u' { return [Decoration]::Underline }
      'invert' { return [Decoration]::Invert }
      'reverse' { return [Decoration]::Invert }
      'conceal' { return [Decoration]::Conceal }
      'blink' { return [Decoration]::SlowBlink }
      'slowblink' { return [Decoration]::SlowBlink }
      'rapidblink' { return [Decoration]::RapidBlink }
      'strike' { return [Decoration]::Strikethrough }
      'strikethrough' { return [Decoration]::Strikethrough }
      's' { return [Decoration]::Strikethrough }
      default { return $null }
    }

    return $null
  }

  static [System.Collections.Generic.List[string]] GetMarkupNames([Decoration]$decoration) {
    $result = [System.Collections.Generic.List[string]]::new()
    $lookup = [ordered]@{
      Bold          = 'bold'
      Dim           = 'dim'
      Italic        = 'italic'
      Underline     = 'underline'
      Invert        = 'invert'
      Conceal       = 'conceal'
      SlowBlink     = 'blink'
      RapidBlink    = 'rapidblink'
      Strikethrough = 'strikethrough'
    }

    foreach ($key in $lookup.Keys) {
      $flag = [Decoration]::$key
      if (($decoration -band $flag) -eq $flag) {
        $result.Add($lookup[$key])
      }
    }

    return $result
  }

  static [Style] ParseStyle([string]$text) {
    $delta = [MarkupStyleParser]::ParseTagState($text)
    return $delta.Style
  }

  static [MarkupStyleStateDelta] ParseTagState([string]$tag) {
    if ([string]::IsNullOrWhiteSpace($tag)) {
      return [MarkupStyleStateDelta]::new([Style]::Plain, $null)
    }

    $foreground = [Color]::Default
    $background = [Color]::Default
    $decoration = [Decoration]::None
    $link = $null
    $parts = $tag.Split(' ', [StringSplitOptions]::RemoveEmptyEntries)
    $isBackground = $false

    foreach ($rawPart in $parts) {
      $part = $rawPart.Trim()
      if ([string]::IsNullOrWhiteSpace($part)) {
        continue
      }

      if ($part -ieq 'on') {
        $isBackground = $true
        continue
      }

      if ($part.StartsWith('link=', [StringComparison]::OrdinalIgnoreCase)) {
        $link = $part.Substring(5)
        continue
      }

      $matchedDecoration = $true
      switch ($part.ToLowerInvariant()) {
        'none' { }
        'bold' { $decoration = $decoration -bor [Decoration]::Bold }
        'b' { $decoration = $decoration -bor [Decoration]::Bold }
        'dim' { $decoration = $decoration -bor [Decoration]::Dim }
        'italic' { $decoration = $decoration -bor [Decoration]::Italic }
        'i' { $decoration = $decoration -bor [Decoration]::Italic }
        'underline' { $decoration = $decoration -bor [Decoration]::Underline }
        'u' { $decoration = $decoration -bor [Decoration]::Underline }
        'invert' { $decoration = $decoration -bor [Decoration]::Invert }
        'reverse' { $decoration = $decoration -bor [Decoration]::Invert }
        'conceal' { $decoration = $decoration -bor [Decoration]::Conceal }
        'blink' { $decoration = $decoration -bor [Decoration]::SlowBlink }
        'slowblink' { $decoration = $decoration -bor [Decoration]::SlowBlink }
        'rapidblink' { $decoration = $decoration -bor [Decoration]::RapidBlink }
        'strike' { $decoration = $decoration -bor [Decoration]::Strikethrough }
        'strikethrough' { $decoration = $decoration -bor [Decoration]::Strikethrough }
        's' { $decoration = $decoration -bor [Decoration]::Strikethrough }
        default { $matchedDecoration = $false }
      }

      if ($matchedDecoration) {
        continue
      }
      $color = if ($part.StartsWith('#')) { [Color]::FromHex($part) } else { [Color]::FromName($part) }
      if ($null -ne $color) {
        if ($isBackground) {
          $background = $color
        } else {
          $foreground = $color
        }
        continue
      }

      throw "Unsupported markup token '$part'."
    }

    return [MarkupStyleStateDelta]::new([Style]::new($foreground, $background, $decoration), $link)
  }

  static [List[MarkupSegmentInfo]] ParseMarkup([string]$markup, [Style]$baseStyle) {
    $result = [List[MarkupSegmentInfo]]::new()
    if ($null -eq $markup) {
      return $result
    }

    $style = if ($null -ne $baseStyle) { $baseStyle } else { [Style]::Plain }
    $styleStack = [Stack[MarkupStyleState]]::new()
    $buffer = [StringBuilder]::new()
    $link = $null
    $index = 0

    while ($index -lt $markup.Length) {
      $current = $markup[$index]

      if ($current -eq '[') {
        if (($index + 1) -lt $markup.Length -and $markup[$index + 1] -eq '[') {
          [void]$buffer.Append('[')
          $index += 2
          continue
        }

        if ($buffer.Length -gt 0) {
          [MarkupStyleParser]::AppendSegment($result, $buffer.ToString(), $style, $link)
          [void]$buffer.Clear()
        }

        $closeIndex = [MarkupStyleParser]::FindTagEnd($markup, $index + 1)
        if ($closeIndex -lt 0) {
          throw "Encountered malformed markup tag at position $index."
        }

        $tag = $markup.Substring($index + 1, $closeIndex - $index - 1)
        if ($tag -eq '/') {
          if ($styleStack.Count -eq 0) {
            throw "Encountered closing tag when none was expected near position $index."
          }

          $state = $styleStack.Pop()
          $style = $state.Style
          $link = $state.Link
        } else {
          $styleStack.Push([MarkupStyleState]::new($style, $link))
          $delta = [MarkupStyleParser]::ParseTagState($tag)
          $style = $style.Combine($delta.Style)
          if (-not [string]::IsNullOrWhiteSpace($delta.Link)) {
            $link = $delta.Link
          }
        }

        $index = $closeIndex + 1
        continue
      }

      if ($current -eq ']') {
        if (($index + 1) -lt $markup.Length -and $markup[$index + 1] -eq ']') {
          [void]$buffer.Append(']')
          $index += 2
          continue
        }

        throw "Encountered unescaped ']' token at position $index."
      }

      [void]$buffer.Append($current)
      $index++
    }

    if ($buffer.Length -gt 0) {
      [MarkupStyleParser]::AppendSegment($result, $buffer.ToString(), $style, $link)
    }

    if ($styleStack.Count -gt 0) {
      Write-Warning "[MarkupStyleParser]::ParseMarkup - Failed! Unbalanced markup stack. Did you forget to close a tag?"
    }

    return $result
  }

  static [string] RemoveMarkup([string]$markup) {
    if ([string]::IsNullOrWhiteSpace($markup)) {
      return [string]::Empty
    }

    $builder = [StringBuilder]::new()
    foreach ($segment in [MarkupStyleParser]::ParseMarkup($markup, [Style]::Plain)) {
      [void]$builder.Append($segment.Text)
    }
    return $builder.ToString()
  }

  static [string] Escape([string]$markup) {
    if ($null -eq $markup) {
      return [string]::Empty
    }

    return $markup.Replace('[', '[[').Replace(']', ']]')
  }

  static hidden [void] AppendSegment([List[MarkupSegmentInfo]]$segments, [string]$text, [Style]$style, [string]$link) {
    if ([string]::IsNullOrEmpty($text)) {
      return
    }

    if ($segments.Count -gt 0) {
      $last = $segments[$segments.Count - 1]
      if ($last.Style.ToMarkup() -eq $style.ToMarkup() -and $last.Link -eq $link) {
        $last.Text += $text
        return
      }
    }

    $segments.Add([MarkupSegmentInfo]::new($text, $style, $link))
  }

  static hidden [int] FindTagEnd([string]$markup, [int]$startIndex) {
    $index = $startIndex
    while ($index -lt $markup.Length) {
      if ($markup[$index] -eq ']') {
        return $index
      }
      $index++
    }
    return -1
  }
}