src/UI/Components/FilteredListPicker.ps1
|
# FilteredListPicker — Componente reusable para listas filtrables inline. # # Antes de este componente, 6 screens replicaron el mismo patrón con variaciones # cosméticas (~570 LoC repetidos). Cada flow nuevo lo replicaba (audit D-1). # Este componente unifica: # - State: $Items, $SelectedIndex, $Filter # - Filter logic: substring case-insensitive sobre LabelFn (default) o MatchFn # custom. Items 'sticky' (siempre visibles aunque no matcheen) via AlwaysShowFn. # - Key handling: ↑↓ navega con wrap, Backspace pop char, char printable append. # Devuelve 'enter' / 'escape' / 'continue' para que la screen decida la acción. # - Render: filter prompt + title + lista filtrada con slice del viewport. # # Cada screen instancia uno (o varios) y le pasa scriptblocks para customizar # label, match, render. Las teclas Enter/Escape se delegan a la screen. class FilteredListPicker { # ─── Data ─────────────────────────────────────────────────────────────── [object[]] $Items = @() [int] $SelectedIndex = 0 [string] $Filter = '' # ─── Display ──────────────────────────────────────────────────────────── [string] $Title = 'Filtrar' # label del prompt [string] $EmptyMessage = '(sin items)' [string] $NoMatchMessage = '(sin matches)' # ─── Customization (scriptblocks) ─────────────────────────────────────── # (item) → string. Plain text para el filter default. Default: $_.ToString() [scriptblock] $LabelFn # (item, needleLower) → bool. Si null, usa LabelFn + ToLower + Contains. [scriptblock] $MatchFn # (item) → bool. Items que se muestran siempre, aun sin matchear el filter. # Útil para parent '..' del browser. [scriptblock] $AlwaysShowFn # (item, isSelected, theme, reset) → string. Cómo renderear cada row. # Si null, usa " · {label}" / "▎ ▶ {label}" simple. [scriptblock] $RenderRowFn FilteredListPicker() { } # ─── Filter logic ─────────────────────────────────────────────────────── # Default match: usa LabelFn -> ToLower -> Contains. Si LabelFn no se setea, # cae a $_.ToString(). $needleLower viene pre-lowercase. hidden [bool] DefaultMatch([object]$item, [string]$needleLower) { $label = if ($null -ne $this.LabelFn) { [string](& $this.LabelFn $item) } else { [string]$item } return $label.ToLowerInvariant().Contains($needleLower) } [object[]] FilteredItems() { if (-not $this.Filter) { return $this.Items } $needle = $this.Filter.ToLowerInvariant() # PS class case-insensitive: $matchFn chocaría con $this.MatchFn. $matcher = $this.MatchFn $stickyFn = $this.AlwaysShowFn $matched = @() foreach ($item in $this.Items) { $sticky = $false if ($null -ne $stickyFn) { $sticky = [bool](& $stickyFn $item) } if ($sticky) { $matched += $item continue } $hit = if ($null -ne $matcher) { [bool](& $matcher $item $needle) } else { $this.DefaultMatch($item, $needle) } if ($hit) { $matched += $item } } return $matched } [int] FilteredCount() { return $this.FilteredItems().Count } # Item actualmente bajo el cursor (de la lista filtrada). [object] Selected() { $visible = $this.FilteredItems() if ($visible.Count -eq 0) { return $null } if ($this.SelectedIndex -lt 0 -or $this.SelectedIndex -ge $visible.Count) { return $null } return $visible[$this.SelectedIndex] } # ─── Mutations ────────────────────────────────────────────────────────── [void] Reset() { $this.Filter = '' $this.SelectedIndex = 0 } [void] SetItems([object[]]$items) { $this.Items = if ($null -eq $items) { @() } else { @($items) } # Clamp del SelectedIndex contra la nueva lista. $count = $this.FilteredItems().Count if ($this.SelectedIndex -ge $count) { $this.SelectedIndex = [Math]::Max(0, $count - 1) } } # ─── Key handling ─────────────────────────────────────────────────────── # Devuelve: # 'enter' → user picked. Caller debe leer Selected() y actuar. # 'escape' → user canceló. Caller decide si volver, limpiar filter, etc. # 'continue' → key consumida (navegación, filter edit). Caller no hace nada. # # NO maneja Tab — eso es del flow de la screen (cycling de focus). [string] HandleKey([System.ConsoleKeyInfo]$key) { $k = $key.Key $c = $key.KeyChar $visible = $this.FilteredItems() if ($k -eq 'Enter') { return 'enter' } if ($k -eq 'Escape') { return 'escape' } if ($k -eq 'UpArrow') { if ($visible.Count -gt 0) { $this.SelectedIndex = if ($this.SelectedIndex -le 0) { $visible.Count - 1 } else { $this.SelectedIndex - 1 } } return 'continue' } if ($k -eq 'DownArrow') { if ($visible.Count -gt 0) { $this.SelectedIndex = if ($this.SelectedIndex -ge $visible.Count - 1) { 0 } else { $this.SelectedIndex + 1 } } return 'continue' } if ($k -eq 'Home') { $this.SelectedIndex = 0; return 'continue' } if ($k -eq 'End') { $this.SelectedIndex = [Math]::Max(0, $visible.Count - 1); return 'continue' } if ($k -eq 'PageUp') { $this.SelectedIndex = [Math]::Max(0, $this.SelectedIndex - 10); return 'continue' } if ($k -eq 'PageDown') { $this.SelectedIndex = [Math]::Min([Math]::Max(0, $visible.Count - 1), $this.SelectedIndex + 10); return 'continue' } if ($k -eq 'Backspace') { if ($this.Filter.Length -gt 0) { $this.Filter = $this.Filter.Substring(0, $this.Filter.Length - 1) $this.SelectedIndex = 0 } return 'continue' } if (-not [char]::IsControl($c)) { $this.Filter += $c $this.SelectedIndex = 0 return 'continue' } return 'continue' } # ─── Render ───────────────────────────────────────────────────────────── # Construye las líneas del body. Devuelve array para que el caller las # appendee a su frame. # $maxVisible = cuántos items entran. Si la screen tiene poco espacio, baja. [string[]] BuildBody([object]$theme, [object]$renderer, [int]$maxVisible) { $reset = [AnsiService]::Reset $visible = $this.FilteredItems() $out = @() # Línea 1: filter prompt destacado $bg = $theme.Bg('acc') $fgInv = $theme.Fg('bg0') $pill = "${bg}${fgInv} $($this.Title) ${reset}" $cursor = $theme.Fg('acc') + '|' + $reset $buf = $theme.Fg('fg0') + $this.Filter + $reset $out += ' ' + $pill + ' ' + $buf + $cursor # Línea 2: section title con count $countText = ' Resultados · ' + $visible.Count if ($this.Filter -and $visible.Count -ne $this.Items.Count) { $countText += ' de ' + $this.Items.Count } if ($this.Filter) { $countText += " · /$($this.Filter)/" } $out += $theme.Fg('fg2') + $countText + $reset $out += $renderer.HRule() # Lista if ($visible.Count -eq 0) { $msg = if ($this.Filter) { ' ' + $this.NoMatchMessage } else { ' ' + $this.EmptyMessage } $out += $theme.Fg('fg3') + $msg + $reset return $out } # Slice del viewport — centrado en SelectedIndex. $effectiveMax = [Math]::Max(3, $maxVisible) $sliceStart = [Math]::Max(0, $this.SelectedIndex - [Math]::Floor($effectiveMax / 2)) $sliceEnd = [Math]::Min($visible.Count, $sliceStart + $effectiveMax) # Re-clamp si el end llegó al final pero el start podría adelantarse. if ($sliceEnd -eq $visible.Count) { $sliceStart = [Math]::Max(0, $sliceEnd - $effectiveMax) } for ($i = $sliceStart; $i -lt $sliceEnd; $i++) { $item = $visible[$i] $sel = ($i -eq $this.SelectedIndex) if ($null -ne $this.RenderRowFn) { $out += [string](& $this.RenderRowFn $item $sel $theme $reset) } else { $marker = if ($sel) { $theme.Fg('acc') + '▎ ▶ ' + $reset } else { ' · ' } $label = if ($null -ne $this.LabelFn) { [string](& $this.LabelFn $item) } else { [string]$item } $color = if ($sel) { 'acc' } else { 'fg1' } $out += $marker + $theme.Fg($color) + $label + $reset } } return $out } } |