Private/UI/Show-ModuleSearchUI.ps1
|
function Show-ModuleSearchUI { param( [string]$InitialQuery = '', [string]$Repository = 'PSGallery' ) $state = @{ Query = $InitialQuery Results = @() SelectedIndex = 0 IsSearching = $false ExactSearch = $false AuthorSearch = $false SortBy = 'Relevance' LastSearchQuery = $null Repository = $Repository StatusMessage = 'Type to search, then press Enter...' DetailMessage = 'No module selected.' NeedsRedraw = $true SpinnerFrame = 0 SearchPS = $null SearchAsync = $null SearchStartTime = $null TagSearch = $false PageSize = 99 CurrentPage = 0 HasMoreResults = $true AllResults = @() IsLoadingMore = $false } $palette = Get-PSMBColorPalette $esc = [char]27 $rst = "${esc}[0m" # Palette shortcuts $pAmber = $palette.Amber $pRose = $palette.Rose $pPeach = $palette.Peach $pText = $palette.Text $pSubtext = $palette.Subtext $pDim = $palette.Dim $pSurface = $palette.Surface $pBold = $palette.Bold $pGreen = $palette.Green $pYellow = $palette.Yellow $pBgSel = $palette.BgSelect function Remove-AnsiEscape { param([string]$Text) return [regex]::Replace($Text, '\x1b\[[0-9;]*m', '') } function Build-Chip { param( [string]$Label, [string]$Value, [string]$Color ) return "${pDim} [${rst}${pAmber}$Label${rst}${pDim}]${rst} ${Color}$Value ${rst}" } function Write-At { param([int]$Row, [int]$Col, [string]$Text) try { [Console]::SetCursorPosition($Col, $Row) [Console]::Write($Text) } catch { } } function Pad-Right { param([string]$Text, [int]$Width) $plain = Remove-AnsiEscape -Text $Text $ansiExtra = $Text.Length - $plain.Length return $Text.PadRight($Width + $ansiExtra) } function Truncate-Plain { param([string]$Text, [int]$MaxLen) if ($Text.Length -gt $MaxLen) { return $Text.Substring(0, $MaxLen - 1) + ([char]0x2026) } return $Text } function Draw-HLine { param([int]$Row, [int]$Col, [int]$Width, [string]$Color = '') Write-At -Row $Row -Col $Col -Text ($Color + ([string]([char]0x2500) * $Width) + $rst) } function Draw-SearchBar { param([hashtable]$Size, [hashtable]$St) $w = $Size.Width $queryDisplay = $St.Query $maxQ = $w - 62 if ($maxQ -lt 10) { $maxQ = 10 } if ($queryDisplay.Length -gt $maxQ) { $queryDisplay = $queryDisplay.Substring($queryDisplay.Length - $maxQ) } $exactChip = if ($St.ExactSearch) { Build-Chip -Label 'EXACT' -Value 'ON' -Color $pGreen } else { Build-Chip -Label 'EXACT' -Value 'OFF' -Color $pYellow } $authorChip = if ($St.AuthorSearch) { Build-Chip -Label 'AUTHOR' -Value 'ON' -Color $pGreen } else { Build-Chip -Label 'AUTHOR' -Value 'OFF' -Color $pYellow } $tagChip = if ($St.TagSearch) { Build-Chip -Label 'TAG' -Value 'ON' -Color $pGreen } else { Build-Chip -Label 'TAG' -Value 'OFF' -Color $pYellow } $sortChip = Build-Chip -Label 'SORT' -Value $St.SortBy.ToUpper() -Color $pPeach $title = "${pBold}${pAmber}PSModuleBrowser${rst} ${pDim}//${rst} ${pRose}BROWSE${rst}" $searchLabel = if ($St.AuthorSearch) { 'Author' } elseif ($St.TagSearch) { 'Tag' } else { 'Search' } $search = "${pBold}${pAmber}$searchLabel${rst}${pDim}:${rst} ${pText}$queryDisplay${rst}${pAmber}$([char]0x2588)${rst}" $hint = "${pDim}Enter=Search${rst}" $line = " $title $search $exactChip $authorChip $tagChip $sortChip $hint" $padding = [Math]::Max(0, $w - (Remove-AnsiEscape $line).Length - 2) Write-At -Row 0 -Col 0 -Text (' ' * $w) Write-At -Row 0 -Col 0 -Text ($line + (' ' * $padding)) Draw-HLine -Row 1 -Col 0 -Width $w -Color $pDim } function Draw-ResultsList { param([hashtable]$Size, [hashtable]$St, [int]$PaneLeft, [int]$PaneWidth, [int]$PaneTop, [int]$PaneHeight) $results = $St.Results $selIdx = $St.SelectedIndex $count = $results.Count $headerText = if ($St.IsSearching) { $spinnerChars = @([char]0x280B, [char]0x2819, [char]0x2839, [char]0x2838, [char]0x283C, [char]0x2834, [char]0x2826, [char]0x2827, [char]0x2807, [char]0x280F) $spinner = $spinnerChars[$St.SpinnerFrame % $spinnerChars.Count] $elapsed = if ($St.SearchStartTime) { ' ({0:F0}s)' -f ([datetime]::UtcNow - $St.SearchStartTime).TotalSeconds } else { '' } "${pPeach}$spinner Searching...$elapsed${rst}" } elseif ($count -eq 0 -and $null -ne $St.LastSearchQuery) { "${pDim}No results${rst}" } else { $countLabel = if ($St.HasMoreResults -and $count -gt 0) { "${count}+" } else { "$count" } "${pBold}Results${rst} ${pDim}($countLabel)${rst}" } Write-At -Row $PaneTop -Col $PaneLeft -Text (' ' * $PaneWidth) Write-At -Row $PaneTop -Col $PaneLeft -Text (" ${pAmber}$([char]0x25A0)${rst} $headerText" + (' ' * 4)) $listTop = $PaneTop + 1 $listHeight = $PaneHeight - 1 $startIdx = [Math]::Max(0, $selIdx - [Math]::Floor($listHeight / 2)) $endIdx = [Math]::Min($count - 1, $startIdx + $listHeight - 1) if ($count -gt 0 -and ($endIdx - $startIdx + 1) -lt $listHeight) { $startIdx = [Math]::Max(0, $endIdx - $listHeight + 1) } for ($rowOff = 0; $rowOff -lt $listHeight; $rowOff++) { $row = $listTop + $rowOff $idx = $startIdx + $rowOff $blank = ' ' * $PaneWidth if ($idx -le $endIdx -and $idx -lt $count) { $m = $results[$idx] $isSelected = ($idx -eq $selIdx) $statusIcon = switch ($m.InstallStatus) { 'Installed' { "${pGreen}$([char]0x2714)${rst}" } 'UpdateAvailable' { "${pYellow}^${rst}" } default { ' ' } } $nameStr = Truncate-Plain -Text $m.Name -MaxLen ([Math]::Floor($PaneWidth * 0.45)) $verStr = Truncate-Plain -Text "v$($m.Version)" -MaxLen 10 $authorStr = Truncate-Plain -Text ($m.Author ?? '') -MaxLen ([Math]::Floor($PaneWidth * 0.25)) $suffix = " ${pDim}$verStr${rst} ${pSubtext}$authorStr${rst}" if ($isSelected) { $lineText = " ${pAmber}$([char]0x276F)${rst} $statusIcon ${pBold}${pAmber}$nameStr${rst}$suffix" } else { $lineText = " $statusIcon ${pBold}$nameStr${rst}$suffix" } $plainLen = (Remove-AnsiEscape $lineText).Length $padding = [Math]::Max(0, $PaneWidth - $plainLen) if ($isSelected) { Write-At -Row $row -Col $PaneLeft -Text ($pBgSel + $lineText + (' ' * $padding) + $rst) } else { Write-At -Row $row -Col $PaneLeft -Text ($lineText + (' ' * $padding)) } } else { Write-At -Row $row -Col $PaneLeft -Text $blank } } } function Draw-DetailsPane { param([hashtable]$Size, [hashtable]$St, [int]$PaneLeft, [int]$PaneWidth, [int]$PaneTop, [int]$PaneHeight) $m = if ($St.Results.Count -gt 0 -and $St.SelectedIndex -lt $St.Results.Count) { $St.Results[$St.SelectedIndex] } else { $null } Write-At -Row $PaneTop -Col $PaneLeft -Text (' ' * $PaneWidth) Write-At -Row $PaneTop -Col $PaneLeft -Text (" ${pRose}$([char]0x25C6)${rst} ${pBold}${pAmber}Module Details${rst}" + (' ' * 4)) $row = $PaneTop + 1 if ($null -eq $m) { Write-At -Row $row -Col $PaneLeft -Text (" ${pDim}No module selected.${rst}" + (' ' * $PaneWidth)) $row++ for (; $row -lt $PaneTop + $PaneHeight; $row++) { Write-At -Row $row -Col $PaneLeft -Text (' ' * $PaneWidth) } return } $statusColor = switch ($m.InstallStatus) { 'Installed' { $pGreen } 'UpdateAvailable' { $pYellow } default { $pDim } } $lines = @() $lines += " ${pBold}${pAmber}$(Truncate-Plain $m.Name ($PaneWidth - 2))${rst}" $lines += " ${pDim}v$($m.Version)${rst} ${statusColor}$($m.InstallStatus)${rst}" $lines += "" if ($m.Author) { $lines += " ${pBold}Author:${rst} $(Truncate-Plain $m.Author ($PaneWidth - 12))" } if ($m.InstalledVersion) { $lines += " ${pBold}Local:${rst} v$($m.InstalledVersion)" } if ($m.PublishedDate -and $m.PublishedDate -ne [datetime]::MinValue) { $lines += " ${pBold}Published:${rst} $($m.PublishedDate.ToString('yyyy-MM-dd'))" } if ($m.DownloadCount -gt 0) { $lines += " ${pBold}Downloads:${rst} $($m.DownloadCount.ToString('N0'))" } if ($m.Repository) { $lines += " ${pBold}Repo:${rst} $($m.Repository)" } if ($m.Description) { $lines += "" $desc = $m.Description $maxDescWidth = $PaneWidth - 3 $maxDescriptionLines = 8 $words = $desc -split '\s+' $currentLine = ' ' foreach ($word in $words) { if (($currentLine + $word).Length -gt $maxDescWidth) { $lines += $currentLine $currentLine = " $word" } else { if ($currentLine -eq ' ') { $currentLine = " $word" } else { $currentLine += " $word" } } if ($lines.Count -ge $maxDescriptionLines) { break } } if ($currentLine -ne ' ' -and $lines.Count -lt ($maxDescriptionLines + 2)) { $lines += $currentLine } } if ($m.Tags -and $m.Tags.Count -gt 0) { $lines += "" $tagLine = " ${pBold}Tags:${rst} " + (($m.Tags | Select-Object -First 5) -join ', ') $lines += Truncate-Plain $tagLine ($PaneWidth - 2) } if ($m.Dependencies -and $m.Dependencies.Count -gt 0) { $lines += "" $lines += " ${pBold}Deps:${rst} $($m.Dependencies.Count) module(s)" } if ($m.ExportedCommands -and $m.ExportedCommands.Count -gt 0) { $lines += "" $lines += " ${pBold}Commands:${rst} $($m.ExportedCommands.Count)" } foreach ($line in $lines) { if ($row -ge $PaneTop + $PaneHeight) { break } $plain = Remove-AnsiEscape -Text $line $padding = [Math]::Max(0, $PaneWidth - $plain.Length) Write-At -Row $row -Col $PaneLeft -Text ($line + (' ' * $padding)) $row++ } for (; $row -lt $PaneTop + $PaneHeight; $row++) { Write-At -Row $row -Col $PaneLeft -Text (' ' * $PaneWidth) } } function Draw-Divider { param([int]$Row, [int]$Col, [int]$Height, [string]$Color = '') for ($r = $Row; $r -lt $Row + $Height; $r++) { Write-At -Row $r -Col $Col -Text ($Color + ([char]0x2502) + $rst) } } function Draw-Footer { param([hashtable]$Size, [hashtable]$St) $w = $Size.Width $row = $Size.Height - 2 Draw-HLine -Row $row -Col 0 -Width $w -Color $pDim $row++ $moreHint = if ($St.HasMoreResults -and $St.Results.Count -gt 0 -and -not $St.IsSearching) { " ${pAmber}0${rst} More" } else { '' } $footer = " ${pAmber}$([char]0x2191)$([char]0x2193)${rst} Nav ${pAmber}Enter${rst} Search ${pAmber}1${rst} Details ${pAmber}2${rst} Install ${pAmber}3${rst} Versions ${pAmber}4${rst} Deps ${pAmber}5${rst} Commands ${pAmber}6${rst} Exact ${pAmber}7${rst} Sort ${pAmber}8${rst} Author ${pAmber}9${rst} Tag${moreHint} ${pRose}Esc${rst} Quit" $padding = [Math]::Max(0, $w - (Remove-AnsiEscape $footer).Length) Write-At -Row $row -Col 0 -Text ($footer + (' ' * $padding)) } function Draw-StatusBar { param([hashtable]$Size, [hashtable]$St, [int]$Row) $w = $Size.Width $msg = "${pPeach}$([char]0x25CF)${rst} ${pAmber}$($St.StatusMessage)${rst}" $padding = [Math]::Max(0, $w - (Remove-AnsiEscape $msg).Length - 2) Write-At -Row $Row -Col 0 -Text (' ' + $msg + (' ' * $padding) + ' ') } function Draw-Layout { param([hashtable]$Size, [hashtable]$St) $w = $Size.Width $h = $Size.Height $headerRows = 2 $footerRows = 2 $statusRows = 1 $availableRows = $h - $headerRows - $footerRows - $statusRows $contentRows = [Math]::Max(1, $availableRows) $leftWidth = [Math]::Floor($w * 0.4) $rightWidth = $w - $leftWidth - 1 $contentTop = $headerRows Draw-SearchBar -Size $Size -St $St Draw-ResultsList -Size $Size -St $St -PaneLeft 0 -PaneWidth $leftWidth -PaneTop $contentTop -PaneHeight $contentRows Draw-Divider -Row $contentTop -Col $leftWidth -Height $contentRows -Color $pDim Draw-DetailsPane -Size $Size -St $St -PaneLeft ($leftWidth + 1) -PaneWidth $rightWidth -PaneTop $contentTop -PaneHeight $contentRows Draw-StatusBar -Size $Size -St $St -Row ($contentTop + $contentRows) Draw-Footer -Size $Size -St $St } function Perform-Search { param([hashtable]$St, [switch]$LoadMore) # Cancel any in-flight search if ($St.SearchPS) { try { $St.SearchPS.Stop() } catch { } try { $St.SearchPS.Dispose() } catch { } $St.SearchPS = $null $St.SearchAsync = $null } if ([string]::IsNullOrWhiteSpace($St.Query)) { $St.Results = @() $St.AllResults = @() $St.LastSearchQuery = $St.Query $St.StatusMessage = 'Type to search, then press Enter...' $St.SelectedIndex = 0 $St.IsSearching = $false $St.CurrentPage = 0 $St.HasMoreResults = $true return } if ($LoadMore) { $St.CurrentPage++ $St.IsLoadingMore = $true } else { $St.CurrentPage = 0 $St.AllResults = @() $St.HasMoreResults = $true $St.IsLoadingMore = $false } $St.IsSearching = $true $St.SpinnerFrame = 0 $St.SearchStartTime = [datetime]::UtcNow $skipCount = $St.CurrentPage * $St.PageSize $St.StatusMessage = if ($LoadMore) { "Loading more results for '$($St.Query)'..." } else { "Searching for '$($St.Query)'..." } Write-PSMBLog -Message "Search started: Query='$($St.Query)' Exact=$($St.ExactSearch) Author=$($St.AuthorSearch) Tag=$($St.TagSearch) Sort=$($St.SortBy) Page=$($St.CurrentPage) Skip=$skipCount" -Level 'INFO' $manifestPath = [System.IO.Path]::Combine($script:PSMBModuleRoot, 'PSModuleBrowser.psd1') $ps = [PowerShell]::Create() [void]$ps.AddScript({ param($ModulePath, $Query, $ExactSearch, $AuthorSearch, $TagSearch, $SortBy, $Repository, $Skip) Import-Module $ModulePath -Force Search-PSModule -Query $Query -ExactSearch:$ExactSearch -AuthorSearch:$AuthorSearch -TagSearch:$TagSearch -SortBy $SortBy -Repository $Repository -Skip $Skip }) [void]$ps.AddParameters(@{ ModulePath = $manifestPath Query = $St.Query ExactSearch = [bool]$St.ExactSearch AuthorSearch = [bool]$St.AuthorSearch TagSearch = [bool]$St.TagSearch SortBy = $St.SortBy Repository = $St.Repository Skip = $skipCount }) $St.SearchPS = $ps $St.SearchAsync = $ps.BeginInvoke() } function Complete-Search { param([hashtable]$St) if (-not $St.SearchPS -or -not $St.SearchAsync) { return $false } if (-not $St.SearchAsync.IsCompleted) { $timeoutSec = 120 if ($St.SearchStartTime -and ([datetime]::UtcNow - $St.SearchStartTime).TotalSeconds -gt $timeoutSec) { try { $St.SearchPS.Stop() } catch { } try { $St.SearchPS.Dispose() } catch { } $St.SearchPS = $null $St.SearchAsync = $null $St.IsSearching = $false $St.IsLoadingMore = $false $St.StatusMessage = "Search timed out after ${timeoutSec}s. Try a more specific query." Write-PSMBLog -Message "Search timed out after ${timeoutSec}s for query='$($St.Query)'" -Level 'WARN' $St.SearchStartTime = $null } return $false } $elapsed = if ($St.SearchStartTime) { ([datetime]::UtcNow - $St.SearchStartTime).TotalSeconds.ToString('F1') } else { '?' } try { $found = $St.SearchPS.EndInvoke($St.SearchAsync) if ($St.SearchPS.Streams.Error.Count -gt 0) { $errMsg = $St.SearchPS.Streams.Error[0].ToString() Write-PSMBLog -Message "Search completed with errors after ${elapsed}s: $errMsg" -Level 'WARN' } $newResults = @($found) $pageSize = $St.PageSize $St.HasMoreResults = $newResults.Count -ge $pageSize if ($St.IsLoadingMore) { # Exclude modules already loaded from previous pages $existingNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($r in $St.AllResults) { [void]$existingNames.Add($r.Name) } $uniqueNew = @($newResults | Where-Object { -not $existingNames.Contains($_.Name) }) $St.AllResults = @($St.AllResults) + $uniqueNew $St.Results = $St.AllResults } else { $St.AllResults = $newResults $St.Results = $newResults $St.SelectedIndex = 0 } $St.LastSearchQuery = $St.Query $totalCount = $St.Results.Count $countDisplay = if ($St.HasMoreResults) { "${totalCount}+" } else { "$totalCount" } $St.StatusMessage = if ($totalCount -eq 0) { "No results found for '$($St.LastSearchQuery)'." } else { "Found $countDisplay module(s) for '$($St.LastSearchQuery)' (${elapsed}s)" } Write-PSMBLog -Message "Search completed: $($newResults.Count) new results, $totalCount total in ${elapsed}s (page $($St.CurrentPage))" -Level 'INFO' } catch { if (-not $St.IsLoadingMore) { $St.Results = @() $St.AllResults = @() } $St.StatusMessage = "Search error: $_" Write-PSMBLog -Message "Search failed after ${elapsed}s: $_" -Level 'ERROR' } finally { $St.IsSearching = $false $St.IsLoadingMore = $false $St.SearchPS.Dispose() $St.SearchPS = $null $St.SearchAsync = $null $St.SearchStartTime = $null } return $true } function Handle-NavigationKey { param([System.ConsoleKeyInfo]$Key, [hashtable]$St) switch ($Key.Key) { 'UpArrow' { if ($St.SelectedIndex -gt 0) { $St.SelectedIndex-- } } 'DownArrow' { if ($St.SelectedIndex -lt $St.Results.Count - 1) { $St.SelectedIndex++ } } 'Backspace' { if ($St.Query.Length -gt 0) { $St.Query = $St.Query.Substring(0, $St.Query.Length - 1) } } } } function Handle-EnterKey { param([hashtable]$St) Perform-Search -St $St } function Handle-ViewDetailsKey { param([hashtable]$St) if ($St.Results.Count -eq 0 -or $St.SelectedIndex -ge $St.Results.Count) { return } $selected = $St.Results[$St.SelectedIndex] [Console]::CursorVisible = $true [Console]::Clear() Show-ModuleDetails -ModuleInfo $selected Write-Host '' Write-Host "${pDim}Press any key to return...${rst}" [Console]::ReadKey($true) | Out-Null [Console]::CursorVisible = $false [Console]::Clear() } function Handle-InstallKey { param([hashtable]$St) if ($St.Results.Count -eq 0) { return } $selected = $St.Results[$St.SelectedIndex] [Console]::CursorVisible = $true [Console]::Clear() $confirmed = Show-InstallConfirmation -ModuleInfo $selected -Version $selected.Version -Scope 'CurrentUser' if ($confirmed) { [Console]::Clear() Write-Host "${pAmber}Installing $($selected.Name) v$($selected.Version)...${rst}" try { Invoke-ProviderInstall -Name $selected.Name -Version $selected.Version -Scope 'CurrentUser' -Repository $St.Repository Write-Host "${pGreen}$([char]0x2714) Installed successfully.${rst}" if ($St.SelectedIndex -lt $St.Results.Count) { $St.Results[$St.SelectedIndex] = Invoke-ProviderGetInfo -Name $selected.Name -Repository $St.Repository } } catch { Write-Host "${pRose}$([char]0x2718) Installation failed: $_${rst}" } Write-Host '' Write-Host "${pDim}Press any key to return...${rst}" [Console]::ReadKey($true) | Out-Null } [Console]::CursorVisible = $false [Console]::Clear() } function Handle-VersionKey { param([hashtable]$St) if ($St.Results.Count -eq 0) { return } $selected = $St.Results[$St.SelectedIndex] [Console]::CursorVisible = $true $versions = Invoke-ProviderGetVersions -Name $selected.Name -Repository $St.Repository $chosenVersion = Show-VersionList -Versions $versions -ModuleName $selected.Name if ($chosenVersion) { $St.StatusMessage = "Selected version: $chosenVersion for $($selected.Name)" } [Console]::CursorVisible = $false [Console]::Clear() } function Handle-DependencyKey { param([hashtable]$St) if ($St.Results.Count -eq 0) { return } $selected = $St.Results[$St.SelectedIndex] [Console]::CursorVisible = $true [Console]::Clear() Show-DependencyList -ModuleInfo $selected Write-Host "${pDim}Press any key to return...${rst}" [Console]::ReadKey($true) | Out-Null [Console]::CursorVisible = $false [Console]::Clear() } function Handle-CommandPreviewKey { param([hashtable]$St) if ($St.Results.Count -eq 0) { return } $selected = $St.Results[$St.SelectedIndex] $previewInfo = $selected $localModule = Get-InstalledModuleInfo -Name $selected.Name $hasMetadataCommands = $selected.ExportedCommands -and $selected.ExportedCommands.Count -gt 0 if (-not $localModule -and -not $hasMetadataCommands) { try { $detailed = Invoke-ProviderGetInfo -Name $selected.Name -Repository $St.Repository if ($detailed) { $previewInfo = $detailed if ($St.SelectedIndex -lt $St.Results.Count) { $St.Results[$St.SelectedIndex] = $detailed } } } catch { } } [Console]::CursorVisible = $true [Console]::Clear() Show-CommandPreview -ModuleInfo $previewInfo Write-Host "${pDim}Press any key to return...${rst}" [Console]::ReadKey($true) | Out-Null [Console]::CursorVisible = $false [Console]::Clear() } function Handle-NumberShortcutKey { param([char]$Char, [hashtable]$St) switch ($Char.ToString()) { '0' { if (-not $St.IsSearching -and $St.HasMoreResults -and $St.Results.Count -gt 0) { Perform-Search -St $St -LoadMore } } '1' { Handle-ViewDetailsKey -St $St } '2' { Handle-InstallKey -St $St } '3' { Handle-VersionKey -St $St } '4' { Handle-DependencyKey -St $St } '5' { Handle-CommandPreviewKey -St $St } '6' { $St.ExactSearch = -not $St.ExactSearch $St.StatusMessage = "Exact search: $($St.ExactSearch)" } '7' { $sortOrders = @('Relevance', 'Downloads', 'LastUpdated') $currentIdx = $sortOrders.IndexOf($St.SortBy) $St.SortBy = $sortOrders[($currentIdx + 1) % $sortOrders.Count] $St.StatusMessage = "Sort order: $($St.SortBy)" } '8' { $St.AuthorSearch = -not $St.AuthorSearch if ($St.AuthorSearch) { $St.TagSearch = $false } $St.StatusMessage = "Author search: $($St.AuthorSearch)" } '9' { $St.TagSearch = -not $St.TagSearch if ($St.TagSearch) { $St.AuthorSearch = $false } $St.StatusMessage = "Tag search: $($St.TagSearch)" } } return $false } # Main TUI Loop $originalCursorVisible = [Console]::CursorVisible try { [Console]::CursorVisible = $false [Console]::Clear() Write-PSMBLog -Message 'TUI started' -Level 'INFO' if ($state.Query) { Perform-Search -St $state } while ($true) { # Check if an async search has completed [void](Complete-Search -St $state) # Advance spinner animation during search if ($state.IsSearching) { $state.SpinnerFrame++ } try { $size = @{ Width = [Console]::WindowWidth; Height = [Console]::WindowHeight } Draw-Layout -Size $size -St $state } catch { } try { [Console]::SetCursorPosition(0, [Console]::WindowHeight - 1) } catch { } # Non-blocking key check keeps the UI responsive during search if ([Console]::KeyAvailable) { $key = [Console]::ReadKey($true) switch ($key.Key) { 'UpArrow' { Handle-NavigationKey -Key $key -St $state } 'DownArrow' { Handle-NavigationKey -Key $key -St $state } 'Backspace' { Handle-NavigationKey -Key $key -St $state } 'Enter' { Handle-EnterKey -St $state } 'Escape' { return } default { $keyName = $key.Key.ToString() if ($keyName -match '^(?:D|NumPad)(\d)$') { [void](Handle-NumberShortcutKey -Char $Matches[1] -St $state) } elseif (-not [char]::IsControl($key.KeyChar) -and $key.KeyChar -match '[a-zA-Z\-_\. ]') { $state.Query += $key.KeyChar $state.StatusMessage = "Press Enter to search for '$($state.Query)'" } } } } else { $sleepMs = if ($state.IsSearching) { 80 } else { 100 } Start-Sleep -Milliseconds $sleepMs } } } finally { if ($state.SearchPS) { try { $state.SearchPS.Stop() } catch { } try { $state.SearchPS.Dispose() } catch { } } [Console]::CursorVisible = $originalCursorVisible [Console]::ResetColor() [Console]::Clear() Write-PSMBLog -Message 'TUI exited' -Level 'INFO' } } |