src/UI/Screens/PreferencesScreen.ps1
|
# PreferencesScreen — Edita Settings vivos. # # Lista de preferencias editables. Cada item es una fila navegable; ↵ alterna # (toggle bool, cycle theme/language). Persiste via SettingsService inmediato # después de cada cambio. # # Diseño deliberado: cero modos extra (input, confirm). Cada acción es atómica — # enter aplica, no hay nada que confirmar. Si surge una pref que requiera input # (ej: ReposPath custom), se suma un modo después. # # Bindings: # ↑↓ / jk navegar # g / G home / end # ↵ alterna/cycle el item bajo cursor # ←/h/Esc back al MainScreen # Q quit # # Cursor + alt buffer son del root loop. Sub-screen NO los toca. class PreferencesScreen { [object] $Theme [object] $Renderer [object] $Primitives [object] $Frame [object] $AppHeader [object] $StatusBar [object] $SettingsSvc [object] $I18n = $null # Inyectable post-construct (MainScreen lo pasa). [object] $Settings = $null [int] $SelectedIndex = 0 [object] $Viewport = [Viewport]::new() [bool] $QuitRequested = $false [bool] $UseAltBuffer = $true [string] $StatusMessage = '' [string] $StatusKind = '' PreferencesScreen($theme, $renderer, $primitives, $frame, $appHeader, $statusBar, $settingsSvc) { $this.Theme = $theme $this.Renderer = $renderer $this.Primitives = $primitives $this.Frame = $frame $this.AppHeader = $appHeader $this.StatusBar = $statusBar $this.SettingsSvc = $settingsSvc } [void] Open() { if ($null -eq $this.SettingsSvc) { throw "PreferencesScreen.Open: SettingsSvc requerido para persistir cambios." } $this.Settings = $this.SettingsSvc.Load() $this.SelectedIndex = 0 $this.Viewport.Reset() $this.StatusMessage = '' $this.StatusKind = '' if ([Console]::IsInputRedirected) { $lines = $this.BuildLines('1.0.0') foreach ($l in $lines) { Write-Host $l } return } # Pre-frame INSTANTÁNEO + Flush para pisar el header del MainScreen sin # esperar a que el while complete EnsureViewport + BuildLines + Write. # Sin esto, hay un breve gap donde el header de la vista anterior queda # visible (Tino lo reportó como parpadeo). $errOut = [Console]::Error $earlyFrame = [AnsiService]::BeginSync() + [AnsiService]::MoveTo(1, 1) + (($this.BuildLines('1.0.0')) -join "`e[K`n") + "`e[K`e[J" + [AnsiService]::EndSync() $errOut.Write($earlyFrame) $errOut.Flush() try { while ($true) { $this.EnsureViewport() $lines = $this.BuildLines('1.0.0') $framePayload = [AnsiService]::BeginSync() + [AnsiService]::MoveTo(1, 1) + ($lines -join "`e[K`n") + "`e[K`e[J" + [AnsiService]::EndSync() $errOut.Write($framePayload) $key = [Console]::ReadKey($true) $exit = $this.HandleKey($key) if ($exit -eq 'back') { return } if ($exit -eq 'quit') { $this.QuitRequested = $true; return } } } finally { } } hidden [string] HandleKey([System.ConsoleKeyInfo]$key) { $k = $key.Key $c = $key.KeyChar $count = $this.Items().Count if ($c -eq 'q' -or $c -eq 'Q') { return 'quit' } if ($k -eq 'LeftArrow' -or $c -eq 'h' -or $k -eq 'Escape') { return 'back' } if ($k -eq 'UpArrow' -or $c -eq 'k') { if ($count -gt 0) { $this.SelectedIndex = if ($this.SelectedIndex -le 0) { $count - 1 } else { $this.SelectedIndex - 1 } } return '' } if ($k -eq 'DownArrow' -or $c -eq 'j') { if ($count -gt 0) { $this.SelectedIndex = if ($this.SelectedIndex -ge $count - 1) { 0 } else { $this.SelectedIndex + 1 } } return '' } if ($k -eq 'Home' -or $c -eq 'g') { $this.SelectedIndex = 0; return '' } if ($k -eq 'End' -or $c -eq 'G') { $this.SelectedIndex = [Math]::Max(0, $count - 1); return '' } if ($k -eq 'Enter') { $this.ApplyCurrent(); return '' } return '' } # Helper i18n con fallback graceful (igual patrón que MainScreen.T). hidden [string] T([string]$key) { if ($null -ne $this.I18n) { return $this.I18n.T($key) } return ([I18nService]::new('es')).T($key) } hidden [string] T([string]$key, [object[]]$values) { if ($null -ne $this.I18n) { return $this.I18n.T($key, $values) } return ([I18nService]::new('es')).T($key, $values) } # Ítems disponibles. Cada uno @{ Key; Label; Type='bool'|'cycle'; Options }. # El render y el handler los usan vía SelectedIndex. Los Labels se traducen # cada vez que se llama (re-render) — refleja el locale actual aunque el # user lo haya cambiado en runtime. [object[]] Items() { $themesAvailable = @($global:RNThemes.Keys | Where-Object { $null -ne $global:RNThemes[$_] } | Sort-Object) return @( @{ Key = 'ThemeKey'; Label = $this.T('prefs.theme'); Type = 'cycle'; Options = $themesAvailable } @{ Key = 'Language'; Label = $this.T('prefs.language'); Type = 'cycle'; Options = @('es', 'en') } @{ Key = 'AutoLoadMode'; Label = $this.T('prefs.autoLoadMode'); Type = 'cycle'; Options = @('All', 'Favorites', 'None') } @{ Key = 'AutoFetch'; Label = $this.T('prefs.autoFetch'); Type = 'bool' } @{ Key = 'FavoritesFirst'; Label = $this.T('prefs.favoritesFirst'); Type = 'bool' } @{ Key = 'ShowAliases'; Label = $this.T('prefs.showAliases'); Type = 'bool' } @{ Key = 'ShowTags'; Label = $this.T('prefs.showTags'); Type = 'bool' } @{ Key = 'ShowLastCommit'; Label = $this.T('prefs.showLastCommit'); Type = 'bool' } @{ Key = 'UseAlternateBuffer'; Label = $this.T('prefs.useAlt'); Type = 'bool' } @{ Key = '_setup'; Label = $this.T('prefs.setupAndStatus'); Type = 'action' } ) } hidden [void] ApplyCurrent() { $items = $this.Items() if ($items.Count -eq 0) { return } if ($this.SelectedIndex -lt 0 -or $this.SelectedIndex -ge $items.Count) { return } $item = $items[$this.SelectedIndex] $key = $item.Key switch ($item.Type) { 'bool' { $this.Settings.$key = -not $this.Settings.$key } 'cycle' { $opts = @($item.Options) if ($opts.Count -le 1) { $this.SetStatus($this.T('prefs.singleValue', @($item.Label)), 'error') return } $current = [string]$this.Settings.$key $idx = [Array]::IndexOf($opts, $current) $next = $opts[(($idx + 1) % $opts.Count)] $this.Settings.$key = $next # Cambios runtime que necesitan propagarse a los services # compartidos para que el re-render siguiente refleje el cambio # sin esperar a que el user salga de Preferences. if ($key -eq 'Language' -and $null -ne $this.I18n) { $this.I18n.SetLocale($next) } elseif ($key -eq 'ThemeKey' -and $null -ne $this.Theme) { try { $this.Theme.SetActive($next) } catch { $null = $_ } } } 'action' { # Acciones no mutan settings; abren sub-screen. $this.RunActionItem($item.Key) return } } $ok = $this.SettingsSvc.Save($this.Settings) if ($ok) { $this.SetStatus($this.T('prefs.savedKv', @($item.Label, $this.Settings.$key)), 'ok') } else { $this.SetStatus($this.T('prefs.saveError'), 'error') } } # Handler para items tipo 'action' — abre sub-screens según el Key. hidden [void] RunActionItem([string]$key) { if ($key -eq '_setup') { $setupSvc = [SetupService]::new() $setupScreen = [SetupScreen]::new( $this.Theme, $this.Renderer, $this.Primitives, $this.Frame, $this.AppHeader, $this.StatusBar, $setupSvc) $setupScreen.UseAltBuffer = $this.UseAltBuffer $setupScreen.I18n = $this.I18n $setupScreen.Open() if ($setupScreen.QuitRequested) { $this.QuitRequested = $true } } } hidden [void] SetStatus([string]$msg, [string]$kind) { $this.StatusMessage = $msg $this.StatusKind = $kind } hidden [void] EnsureViewport() { $count = $this.Items().Count if ($count -eq 0) { $this.Viewport.Reset(); return } $available = [Console]::WindowHeight - 8 $this.Viewport.Resize($available, 1) $this.Viewport.EnsureVisible($this.SelectedIndex, $count) } [string[]] BuildLines([string]$version) { $reset = [AnsiService]::Reset $r = $this.Renderer $items = $this.Items() $lines = [System.Collections.Generic.List[string]]::new() $title = $this.T('prefs.title') # 1. Title bar $lines.Add($this.Frame.TitleBar('repo-nav', $title, $version)) # 2. AppHeader: breadcrumb terminado en el title del screen. $lines.Add($this.AppHeader.Render(@(), $title, @())) $lines.Add($r.HRule()) # 3. Status / línea fija para no saltar if ($this.StatusMessage) { $color = if ($this.StatusKind -eq 'error') { 'gitConflict' } else { 'gitClean' } $lines.Add(' ' + $this.Theme.Fg($color) + $this.StatusMessage + $reset) } else { $lines.Add('') } # 4. Section title $lines.Add($this.Theme.Fg('fg2') + ' ' + $title + ' · ' + $items.Count + $reset) $lines.Add($r.HRule()) # 5. Filas if ($null -ne $this.Viewport -and $this.Viewport.Capacity -gt 0 -and -not [Console]::IsInputRedirected) { $sliceStart = $this.Viewport.Start $sliceEnd = $this.Viewport.EndExclusive($items.Count) } else { $sliceStart = 0 $sliceEnd = $items.Count } for ($i = $sliceStart; $i -lt $sliceEnd; $i++) { $item = $items[$i] $lines.Add($this.RenderItemRow($item, ($i -eq $this.SelectedIndex))) } # 6. StatusBar $hints = @( @{ k = '↑↓'; label = $this.T('hint.move') } @{ k = '↵'; label = $this.T('hint.toggleCycle') } @{ k = '←'; label = $this.T('hint.back') } @{ k = 'Q'; label = $this.T('hint.quit') } ) $lines.Add($r.HRule()) return @($lines.ToArray()) + $this.StatusBar.RenderAnchored($hints, '', $lines.Count) } hidden [string] RenderItemRow([hashtable]$item, [bool]$selected) { $reset = [AnsiService]::Reset $marker = if ($selected) { $this.Theme.Fg('acc') + '▎' + $reset } else { ' ' } $cursor = if ($selected) { $this.Theme.Fg('acc') + '▶' + $reset } else { $this.Theme.Fg('fg3') + '·' + $reset } $labelFg = if ($selected) { 'acc' } else { 'fg1' } $label = [Renderer]::PadRight($this.Theme.Fg($labelFg) + $item.Label + $reset, 22) $val = '' # Solo leemos $this.Settings.X cuando el item lo necesita (bool/cycle). # Para 'action' no hay valor — mostramos un indicador `→`. if ($item.Type -eq 'action') { $val = '→' } elseif ($null -ne $this.Settings) { $raw = $this.Settings.($item.Key) $val = switch ($item.Type) { 'bool' { if ($raw) { $this.T('common.on') } else { $this.T('common.off') } } 'cycle' { [string]$raw } default { [string]$raw } } } $valColor = switch ($item.Type) { 'bool' { if ($val -eq 'on') { 'gitClean' } else { 'fg3' } } 'cycle' { 'gitAhead' } 'action' { 'acc' } default { 'fg1' } } $valFmt = $this.Theme.Fg($valColor) + $val + $reset return "$marker $cursor $label $valFmt" } } |