src/UI/02-List.ps1

# 02-List.ps1 — the shared list/navigation/filter machinery every screen
# uses, so navigation feels identical everywhere (#22).
#
# A list state is a hashtable: @{ Cursor; Top; Filter; FilterMode }.
# Conventions (uniform across ALL psmm screens):
# up/dn pgup/pgdn home/end move / enter filter (search) mode
# enter act on row esc clear filter, else back
# ? screen help Ctrl+Q/X hard quit

function script:New-PSMMListState {
    @{ Cursor = 0; Top = 0; Filter = ''; FilterMode = $false }
}

# Handle a movement key against a list of $Count rows. Returns $true when the
# key was a movement key (handled), $false otherwise.
function script:Invoke-PSMMListNav {
    param(
        [Parameter(Mandatory)] $State,
        [Parameter(Mandatory)] $KeyInfo,
        [Parameter(Mandatory)][int]$Count,
        [int]$Page = 10
    )
    $max = [Math]::Max(0, $Count - 1)
    switch ($KeyInfo.Key) {
        ([ConsoleKey]::UpArrow)   { $State.Cursor = [Math]::Max(0, $State.Cursor - 1); return $true }
        ([ConsoleKey]::DownArrow) { $State.Cursor = [Math]::Min($max, $State.Cursor + 1); return $true }
        ([ConsoleKey]::PageUp)    { $State.Cursor = [Math]::Max(0, $State.Cursor - $Page); return $true }
        ([ConsoleKey]::PageDown)  { $State.Cursor = [Math]::Min($max, $State.Cursor + $Page); return $true }
        ([ConsoleKey]::Home)      { $State.Cursor = 0; return $true }
        ([ConsoleKey]::End)       { $State.Cursor = $max; return $true }
    }
    return $false
}

# Handle a key while in filter mode. Mutates $State; returns one of:
# 'edited' filter text changed / still editing
# 'apply' user pressed enter (keep filter, leave editing)
# 'clear' user pressed esc (filter cleared, left editing)
# '' not a filter key (caller may treat as movement)
function script:Invoke-PSMMFilterKey {
    param(
        [Parameter(Mandatory)] $State,
        [Parameter(Mandatory)] $KeyInfo
    )
    $ctrl = ($KeyInfo.Modifiers -band [ConsoleModifiers]::Control) -ne 0
    switch ($KeyInfo.Key) {
        ([ConsoleKey]::Escape)    { $State.Filter = ''; $State.FilterMode = $false; $State.Cursor = 0; return 'clear' }
        ([ConsoleKey]::Enter)     { $State.FilterMode = $false; return 'apply' }
        ([ConsoleKey]::Backspace) {
            if ($State.Filter.Length) { $State.Filter = $State.Filter.Substring(0, $State.Filter.Length - 1); $State.Cursor = 0 }
            return 'edited'
        }
        default {
            if (-not $ctrl -and $KeyInfo.KeyChar -and $KeyInfo.KeyChar -ne '/' -and -not [char]::IsControl($KeyInfo.KeyChar)) {
                $State.Filter += $KeyInfo.KeyChar; $State.Cursor = 0
                return 'edited'
            }
            return ''
        }
    }
}

# Clamp cursor + viewport top for $Count rows in a $Rows-tall viewport.
# Returns @{ First; Last; Rows } of the visible slice.
function script:Get-PSMMViewport {
    param(
        [Parameter(Mandatory)] $State,
        [Parameter(Mandatory)][int]$Count,
        [Parameter(Mandatory)][int]$Rows
    )
    $rows = [Math]::Max(3, $Rows)
    if ($State.Cursor -ge $Count) { $State.Cursor = [Math]::Max(0, $Count - 1) }
    if ($State.Cursor -lt $State.Top) { $State.Top = $State.Cursor }
    elseif ($State.Cursor -ge $State.Top + $rows) { $State.Top = $State.Cursor - $rows + 1 }
    $State.Top = [Math]::Max(0, [Math]::Min($State.Top, [Math]::Max(0, $Count - $rows)))
    @{ First = $State.Top; Last = [Math]::Min($Count - 1, $State.Top + $rows - 1); Rows = $rows }
}

# Position indicator "row X/n" (+ scroll range when clipped) - every
# scrollable list shows this (#12).
function script:Get-PSMMPositionMarkup {
    param(
        [Parameter(Mandatory)] $State,
        [Parameter(Mandatory)][int]$Count,
        [Parameter(Mandatory)] $Viewport
    )
    if (-not $Count) { return '' }
    $pos = "[$script:PSMM_ColMute] row $($State.Cursor + 1)/$Count[/]"
    if ($Count -gt $Viewport.Rows) {
        $up = if ($Viewport.First -gt 0) { ' ^' } else { '' }
        $dn = if ($Viewport.Last -lt $Count - 1) { ' v' } else { '' }
        $pos += "[$script:PSMM_ColMute] showing $($Viewport.First + 1)-$($Viewport.Last + 1)$up$dn[/]"
    }
    $pos
}

# Filter status markup for a list header ('' when no filter in play).
function script:Get-PSMMFilterMarkup {
    param([Parameter(Mandatory)] $State)
    if ($State.FilterMode) { return " [$script:PSMM_ColAccent]filter: $(ConvertTo-PSMMSafe $State.Filter)_[/]" }
    if ($State.Filter)     { return " [$script:PSMM_ColAccent]filter: $(ConvertTo-PSMMSafe $State.Filter)[/] [$script:PSMM_ColMute](esc clears)[/]" }
    ''
}

# --- shared scrolling text pager (help screens, command help tabs) --------

# Build the renderable for a page of text. $State: @{ Scroll }.
function script:Build-PSMMPagerView {
    param(
        [Parameter(Mandatory)] $State,
        # AllowEmptyString: Mandatory on [string[]] otherwise rejects the
        # blank lines every text document contains
        [Parameter(Mandatory)][AllowEmptyString()][AllowEmptyCollection()][string[]]$Lines,
        [Parameter(Mandatory)][string]$TitleMarkup,
        [string[]]$HintPairs = @('up/dn=scroll', 'esc=back', 'Ctrl+Q=quit'),
        [int]$ReservedRows = 7
    )
    $win  = Get-PSMMWinSize
    $page = [Math]::Max(5, $win.Height - $ReservedRows)
    $State.Scroll = [Math]::Max(0, [Math]::Min($State.Scroll, [Math]::Max(0, $Lines.Count - $page)))
    $body = ($Lines | Select-Object -Skip $State.Scroll -First $page | ForEach-Object { ConvertTo-PSMMSafe $_ }) -join "`n"
    $pos = if ($Lines.Count -gt $page) {
        " [$script:PSMM_ColMute]lines $($State.Scroll + 1)-$([Math]::Min($Lines.Count, $State.Scroll + $page))/$($Lines.Count)[/]"
    } else { '' }
    $items = [System.Collections.Generic.List[Spectre.Console.Rendering.IRenderable]]::new()
    $items.Add([Spectre.Console.Markup]::new("$TitleMarkup$pos"))
    $panel = [Spectre.Console.Panel]::new([Spectre.Console.Markup]::new($body))
    $panel.Border = [Spectre.Console.BoxBorder]::Rounded
    $panel.BorderStyle = [Spectre.Console.Style]::Parse($script:PSMM_ColMute)
    $items.Add($panel)
    $items.Add([Spectre.Console.Markup]::new((Get-PSMMHint -Pairs $HintPairs)))
    [Spectre.Console.Rows]::new($items)
}

# Handle a pager key. Returns $true when handled.
function script:Invoke-PSMMPagerNav {
    param(
        [Parameter(Mandatory)] $State,
        [Parameter(Mandatory)] $KeyInfo
    )
    switch ($KeyInfo.Key) {
        ([ConsoleKey]::UpArrow)   { $State.Scroll = [Math]::Max(0, $State.Scroll - 1); return $true }
        ([ConsoleKey]::DownArrow) { $State.Scroll++; return $true }
        ([ConsoleKey]::PageUp)    { $State.Scroll = [Math]::Max(0, $State.Scroll - 10); return $true }
        ([ConsoleKey]::PageDown)  { $State.Scroll += 10; return $true }
        ([ConsoleKey]::Home)      { $State.Scroll = 0; return $true }
        ([ConsoleKey]::End)       { $State.Scroll = [int]::MaxValue; return $true }
    }
    return $false
}

# Full-screen interactive pager over prepared lines.
function script:Show-PSMMPager {
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '',
        Justification = 'Lines and TitleMarkup are used inside the live-loop scriptblock; the rule cannot see into it.')]
    param(
        # AllowEmptyString: see Build-PSMMPagerView
        [Parameter(Mandatory)][AllowEmptyString()][AllowEmptyCollection()][string[]]$Lines,
        [Parameter(Mandatory)][string]$TitleMarkup
    )
    $st = @{ Scroll = 0 }
    Clear-PSMMScreen
    Invoke-PSMMLive -Body {
        param($ctx)
        while ($true) {
            if ($script:PSMM_UI.HardQuit) { return }
            $ctx.UpdateTarget((Build-PSMMPagerView -State $st -Lines $Lines -TitleMarkup $TitleMarkup))
            $ctx.Refresh()
            $k = Read-PSMMKeyResize
            if ($null -eq $k) { continue }
            if (Test-PSMMHardQuitKey $k) { $script:PSMM_UI.HardQuit = $true; return }
            if (Invoke-PSMMPagerNav -State $st -KeyInfo $k) { continue }
            if ($k.Key -eq [ConsoleKey]::Escape) { return }
        }
    }
    Clear-PSMMScreen
}