Private/Console/List.psm1

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

using module .\Ansi.psm1
using module .\Colors.psm1
using module ..\Enums.psm1
using module ..\Abstracts.psm1
using module .\Progress.psm1
using module .\Prompts.psm1
using module .\Rendering.psm1
using module .\Widgets.psm1

class ListPromptConstants {
  static [string]$Instructions = '(Type to filter, arrows to move, enter to select)'
  static [string]$NoMatches = 'No matches'
  static [int]$DefaultPageSize = 10
}

class ListPromptItem {
  [string]$Title
  [object]$Data
  [bool]$Selected = $false

  ListPromptItem([string]$title, [object]$data) {
    $this.Title = $title
    $this.Data = $data
  }
}

class ListPromptKeyInput {
  [bool]$Accepted
  [bool]$Cancelled
  [string]$Character
  [ConsoleKey]$Key

  ListPromptKeyInput([ConsoleKey]$key, [string]$character, [bool]$accepted, [bool]$cancelled) {
    $this.Key = $key
    $this.Character = $character
    $this.Accepted = $accepted
    $this.Cancelled = $cancelled
  }
}

class ListPromptState {
  [List[ListPromptItem]]$Items
  [List[int]]$FilteredIndexes
  [string]$SearchFilter = ''
  [int]$SelectedFilteredIndex = 0
  [int]$PageSize = 10

  ListPromptState() {
    $this.Items = [List[ListPromptItem]]::new()
    $this.FilteredIndexes = [List[int]]::new()
    $this.ApplyFilter()
  }

  ListPromptState([List[ListPromptItem]]$items, [int]$pageSize) {
    $this.Items = ($null -ne $items) ? $items : [List[ListPromptItem]]::new()
    $this.PageSize = [Math]::Max(1, $pageSize)
    $this.FilteredIndexes = [List[int]]::new()
    $this.ApplyFilter()
  }

  [void] ApplyFilter() {
    $this.FilteredIndexes.Clear()
    for ($i = 0; $i -lt $this.Items.Count; $i++) {
      if ([string]::IsNullOrEmpty($this.SearchFilter) -or
        $this.Items[$i].Title.IndexOf($this.SearchFilter, [StringComparison]::OrdinalIgnoreCase) -ge 0) {
        $this.FilteredIndexes.Add($i)
      }
    }

    if ($this.FilteredIndexes.Count -eq 0) {
      $this.SelectedFilteredIndex = 0
    } elseif ($this.SelectedFilteredIndex -ge $this.FilteredIndexes.Count) {
      $this.SelectedFilteredIndex = $this.FilteredIndexes.Count - 1
    } elseif ($this.SelectedFilteredIndex -lt 0) {
      $this.SelectedFilteredIndex = 0
    }
  }

  [void] SetFilter([string]$filter) {
    $this.SearchFilter = ($null -ne $filter) ? $filter : ''
    $this.SelectedFilteredIndex = 0
    $this.ApplyFilter()
  }

  [void] AppendFilter([char]$character) {
    if ([char]::IsControl($character)) { return }
    $this.SearchFilter += $character
    $this.ApplyFilter()
  }

  [void] BackspaceFilter() {
    if ($this.SearchFilter.Length -gt 0) {
      $this.SearchFilter = $this.SearchFilter.Substring(0, $this.SearchFilter.Length - 1)
      $this.ApplyFilter()
    }
  }

  [void] MoveNext() {
    if ($this.FilteredIndexes.Count -eq 0) { return }
    $this.SelectedFilteredIndex = ($this.SelectedFilteredIndex + 1) % $this.FilteredIndexes.Count
  }

  [void] MovePrevious() {
    if ($this.FilteredIndexes.Count -eq 0) { return }
    $this.SelectedFilteredIndex--
    if ($this.SelectedFilteredIndex -lt 0) { $this.SelectedFilteredIndex = $this.FilteredIndexes.Count - 1 }
  }

  [ListPromptItem] Current() {
    if ($this.FilteredIndexes.Count -eq 0) { return $null }
    return $this.Items[$this.FilteredIndexes[$this.SelectedFilteredIndex]]
  }

  [int] CurrentIndex() {
    if ($this.FilteredIndexes.Count -eq 0) { return -1 }
    return $this.FilteredIndexes[$this.SelectedFilteredIndex]
  }

  [int] PageStart() {
    if ($this.FilteredIndexes.Count -eq 0) { return 0 }
    $start = [Math]::Max(0, $this.SelectedFilteredIndex - [Math]::Floor($this.PageSize / 2))
    if ($start + $this.PageSize -gt $this.FilteredIndexes.Count) {
      $start = [Math]::Max(0, $this.FilteredIndexes.Count - $this.PageSize)
    }
    return $start
  }
}

class ListPromptTree {
  [ListPromptState]$State

  ListPromptTree([ListPromptState]$state) {
    $this.State = $state
  }

  [List[ListPromptItem]] GetVisibleItems() {
    $items = [List[ListPromptItem]]::new()
    $start = $this.State.PageStart()
    $end = [Math]::Min($start + $this.State.PageSize, $this.State.FilteredIndexes.Count)
    for ($i = $start; $i -lt $end; $i++) {
      $items.Add($this.State.Items[$this.State.FilteredIndexes[$i]])
    }
    return $items
  }
}

class ListPromptRenderHoo {
  [string] Invoke([string]$text) {
    return $text
  }
}

class ListPrompt : IPrompt {
  [string]$Title
  [List[ListPromptItem]]$Items
  [string]$SearchFilter = ''
  [int]$PageSize = 10
  [Style]$HighlightStyle = [Style]::new([Color]::Blue)
  [Style]$FilterStyle = [Style]::new([Color]::Yellow)
  [Style]$MutedStyle = [Style]::new([Color]::FromName('Grey58'))
  [bool]$ShowInstructions = $true

  hidden [ListPromptState]$_state

  ListPrompt() {
    $this.Initialize($null)
  }

  ListPrompt([string]$title) {
    $this.Initialize($title)
  }

  hidden [void] Initialize([string]$title) {
    $this.Title = $title
    $this.Items = [List[ListPromptItem]]::new()
    $this.PageSize = [ListPromptConstants]::DefaultPageSize
  }

  [void] AddItem([string]$title) {
    $this.AddItem($title, $title)
  }

  [void] AddItem([string]$title, [object]$data) {
    $this.Items.Add([ListPromptItem]::new($title, $data))
  }

  [void] AddItems([string[]]$items) {
    foreach ($item in $items) {
      $this.AddItem($item, $item)
    }
  }

  [object] Show([object]$consoleInput) {
    if ($null -eq $consoleInput) { throw 'Console cannot be null' }
    $writer = [ConsoleResolver]::ResolveWriter($consoleInput)
    $this._state = [ListPromptState]::new($this.Items, $this.PageSize)
    $this._state.SetFilter($this.SearchFilter)
    $region = [LiveDisplayRegion]::new($writer)
    $region.Begin()

    try {
      while ($true) {
        $region.Render($this.RenderLines($writer))
        $input = $this.ReadInput()

        if ($input.Accepted) {
          $current = $this._state.Current()
          return ($null -ne $current) ? $current.Data : $null
        }

        if ($input.Cancelled) {
          return $null
        }

        switch ($input.Key) {
          ([ConsoleKey]::UpArrow) { $this._state.MovePrevious(); break }
          ([ConsoleKey]::DownArrow) { $this._state.MoveNext(); break }
          ([ConsoleKey]::Backspace) { $this._state.BackspaceFilter(); break }
          default {
            if (![string]::IsNullOrEmpty($input.Character)) {
              $this._state.AppendFilter($input.Character[0])
            }
          }
        }
      }
    } finally {
      $region.Complete($this.RenderFinal($writer))
    }

    return $null
  }

  [string[]] Preview() {
    $this._state = [ListPromptState]::new($this.Items, $this.PageSize)
    $this._state.SetFilter($this.SearchFilter)
    $writer = [AnsiWriter]::new([System.IO.StringWriter]::new())
    $writer.Capabilities.Ansi = $false
    return $this.RenderLines($writer)
  }

  hidden [ListPromptKeyInput] ReadInput() {
    $key = [Console]::ReadKey($true)
    $accepted = $key.Key -eq [ConsoleKey]::Enter
    $cancelled = $key.Key -eq [ConsoleKey]::Escape
    $character = if ($key.KeyChar -ne 0 -and -not [char]::IsControl($key.KeyChar)) { $key.KeyChar.ToString() } else { '' }
    return [ListPromptKeyInput]::new($key.Key, $character, $accepted, $cancelled)
  }

  hidden [string[]] RenderLines([AnsiWriter]$writer) {
    if ($null -eq $this._state) {
      $this._state = [ListPromptState]::new($this.Items, $this.PageSize)
      $this._state.SetFilter($this.SearchFilter)
    }

    $lines = [List[string]]::new()
    if (![string]::IsNullOrWhiteSpace($this.Title)) {
      $lines.Add($this.MarkupToAnsi($writer, $this.Title, [Style]::Plain))
    }

    $filterText = if ([string]::IsNullOrEmpty($this._state.SearchFilter)) { '' } else { $this._state.SearchFilter }
    $lines.Add($this.MarkupToAnsi($writer, "Filter: $filterText", $this.FilterStyle))

    if ($this.ShowInstructions) {
      $lines.Add($this.MarkupToAnsi($writer, [ListPromptConstants]::Instructions, $this.MutedStyle))
    }

    if ($this._state.FilteredIndexes.Count -eq 0) {
      $lines.Add($this.MarkupToAnsi($writer, [ListPromptConstants]::NoMatches, $this.MutedStyle))
      return $lines.ToArray()
    }

    $start = $this._state.PageStart()
    $end = [Math]::Min($start + $this._state.PageSize, $this._state.FilteredIndexes.Count)
    for ($i = $start; $i -lt $end; $i++) {
      $itemIndex = $this._state.FilteredIndexes[$i]
      $item = $this._state.Items[$itemIndex]
      $isCurrent = $i -eq $this._state.SelectedFilteredIndex
      $prefix = if ($isCurrent) { '> ' } else { ' ' }
      $style = if ($isCurrent) { $this.HighlightStyle } else { [Style]::Plain }
      $displayTitle = $this.HighlightFilter($item.Title)
      $lines.Add($this.MarkupToAnsi($writer, $prefix + $displayTitle, $style))
    }

    return $lines.ToArray()
  }

  hidden [string[]] RenderFinal([AnsiWriter]$writer) {
    if ($null -eq $this._state) { return [string[]]@() }
    $current = $this._state.Current()
    if ($null -eq $current) { return [string[]]@() }
    $text = if ([string]::IsNullOrWhiteSpace($this.Title)) { $current.Title } else { "$($this.Title) [green]$($current.Title)[/]" }
    return @($this.MarkupToAnsi($writer, $text, [Style]::Plain))
  }

  hidden [string] HighlightFilter([string]$title) {
    if ([string]::IsNullOrEmpty($this._state.SearchFilter)) {
      return [AnsiMarkup]::Escape($title)
    }

    return [AnsiMarkup]::Highlight([AnsiMarkup]::Escape($title), $this._state.SearchFilter, $this.FilterStyle)
  }

  hidden [string] MarkupToAnsi([AnsiWriter]$writer, [string]$text, [Style]$style) {
    $markup = [Markup]::new($text, $style)
    $options = [RenderOptions]::Create($writer, $writer.Capabilities)
    $segments = $markup.Render($options, 120)
    $builder = [StringBuilder]::new()

    foreach ($segment in $segments) {
      $hasStyle = $writer.Capabilities.Ansi -and $null -ne $segment.Style -and $segment.Style.ToMarkup() -ne [Style]::Plain.ToMarkup()
      if ($hasStyle) {
        [void]$builder.Append("`e[" + [AnsiCodeBuilder]::GetAnsi($segment.Style, $writer.Capabilities.ColorSystem) + 'm')
      }
      [void]$builder.Append($segment.Text)
      if ($hasStyle) {
        [void]$builder.Append("`e[0m")
      }
    }

    return $builder.ToString()
  }
}