src/UI/Viewport.ps1

# Viewport — Ventana de scroll sobre una lista de items.
#
# Responsabilidad ÚNICA: dado un cursor (selectedIndex) y un total (totalCount),
# decidir qué slice [Start, EndExclusive) es visible, manteniendo un margen de
# scrolloff alrededor del cursor.
#
# NO sabe de Console, Renderer ni rendering. Es lógica pura sobre enteros, fácil
# de testear sin terminal real.
#
# Caller (MainScreen) calcula el available height (terminal − chrome) y el
# rows-per-item (compact vs wide) y se los pasa al Resize.

class Viewport {
    [int] $Start = 0        # Primer item visible (índice base 0 en la lista total).
    [int] $Capacity = 1     # Cuántos items entran en la ventana actual.
    [int] $Scrolloff = 2    # Margen mínimo de items entre el cursor y los bordes.

    Viewport() { }

    Viewport([int]$scrolloff) {
        if ($scrolloff -lt 0) {
            throw "Viewport: scrolloff no puede ser negativo (recibí $scrolloff)."
        }
        $this.Scrolloff = $scrolloff
    }

    # Recalcula Capacity según el alto disponible y cuántas filas físicas ocupa
    # cada item (1 en wide, 2 en compact).
    # No toca Start — eso lo decide EnsureVisible. Capacity nunca queda en 0.
    [void] Resize([int]$availableHeight, [int]$rowsPerItem) {
        if ($rowsPerItem -le 0) { $rowsPerItem = 1 }
        $cap = [Math]::Floor($availableHeight / $rowsPerItem)
        $this.Capacity = [Math]::Max(1, [int]$cap)
    }

    # Ajusta Start para que selectedIndex quede visible con scrolloff.
    # Patrón "scrolloff": el cursor mantiene un margen contra los bordes; cuando
    # los cruza, el viewport se mueve lo justo para devolverle el margen.
    #
    # Si scrolloff es mayor que la mitad de Capacity, lo bajamos a esa mitad —
    # sino el cursor se quedaría siempre en el centro y nunca alcanzaría los bordes.
    [void] EnsureVisible([int]$selectedIndex, [int]$totalCount) {
        if ($totalCount -le 0) {
            $this.Start = 0
            return
        }

        $effectiveScrolloff = [Math]::Min($this.Scrolloff, [Math]::Floor($this.Capacity / 2))

        # Borde inferior: cursor cae dentro de los últimos $effectiveScrolloff slots.
        if ($selectedIndex -ge $this.Start + $this.Capacity - $effectiveScrolloff) {
            $this.Start = $selectedIndex - $this.Capacity + 1 + $effectiveScrolloff
        }
        # Borde superior: cursor cae dentro de los primeros $effectiveScrolloff slots.
        if ($selectedIndex -lt $this.Start + $effectiveScrolloff) {
            $this.Start = $selectedIndex - $effectiveScrolloff
        }

        # Clamp final.
        $maxStart = [Math]::Max(0, $totalCount - $this.Capacity)
        if ($this.Start -gt $maxStart) { $this.Start = $maxStart }
        if ($this.Start -lt 0)         { $this.Start = 0 }
    }

    # Índice exclusive del último item visible — listo para usar en for loops:
    # for ($i = $vp.Start; $i -lt $vp.EndExclusive($total); $i++) { ... }
    [int] EndExclusive([int]$totalCount) {
        return [Math]::Min($totalCount, $this.Start + $this.Capacity)
    }

    # Vuelve al top. Útil al cambiar de contexto (drill-in, reload total).
    [void] Reset() {
        $this.Start = 0
    }
}