Skip to content

Instantly share code, notes, and snippets.

@yumura
Last active April 6, 2023 18:58
Show Gist options
  • Save yumura/8df37c22ae1b7942dec7 to your computer and use it in GitHub Desktop.
Save yumura/8df37c22ae1b7942dec7 to your computer and use it in GitHub Desktop.
powershell peco
# Load
Split-Path $MyInvocation.MyCommand.Path -Parent | Push-Location
Get-ChildItem poco_*.ps1 | %{. $_}
Pop-Location
function Select-Poco
{
Param
(
[Object[]]$Property = $null,
[string]$Query = '',
[ValidateSet('match', 'like', 'eq')]
[string]$Filter = 'match',
[switch]$CaseSensitive = $false,
[switch]$InvertFilter = $false,
[string]$Prompt = 'Query',
[ValidateSet('TopDown', 'BottomUp')]
[string]$Layout = 'TopDown',
[HashTable]$Keymaps = (New-PocoKeymaps)
)
$Items = $input | %{,$_}
$config = New-Config $Items $Property $Prompt $Layout $Keymaps # immutable
$state = New-State $Query $Filter $CaseSensitive $InvertFilter $config # mutable
Backup-ScrBuf
Clear-Host
$action = 'None'
while ($action -ne 'Cancel' -and $action -ne 'Finish')
{
Write-Screen $state $config
$key, $keystr = Get-PocoKey
$action = Get-Action $config $keystr
$state = Update-State $state $config $action $key
}
Restore-ScrBuf
if ($action -eq 'Finish') {$state.Entry}
trap
{
Restore-ScrBuf
break
}
}
Set-Alias poco Select-Poco
Export-ModuleMember -Function "*-Poco*" -Alias "poco"
function New-Config ($Items, $Property, $Prompt, $Layout, $Keymaps)
{
@{
'Input' = $Items
'Property' = $Property
'Prompt' = $Prompt
'Layout' = $Layout
'Keymaps' = $Keymaps
}
}
function New-State ($Query, $Filter, $CaseSensitive, $InvertFilter, $Config)
{
$state = @{
'Query' = $Query
'Filter' = $Filter
'CaseSensitive' = $CaseSensitive
'InvertFilter' = $InvertFilter
'Acrion' = 'Identity'
'Entry' = @()
'PrevLength' = 0
'Screen' = @{
'Prompt' = ''
'FilterType' = ''
'QueryX' = 0
'X' = 0
# 'Y' = 1 # 選択機能があれば...
}
}
$state.Screen.Prompt = Get-Prompt $state $config
$state.Screen.FilterType = Get-FilterType $state
$state.Screen.QueryX = $Query.Length
$state.Screen.X = $state.Screen.Prompt.Length
$state.Entry = Get-Entry $state $config
$state
}
function New-PocoKeymaps
{
@{
'Escape' = 'Cancel'
'Control+C' = 'Cancel'
'Enter' = 'Finish'
'Alt+B' = 'BackwardChar'
'Alt+F' = 'ForwardChar'
'Alt+A' = 'BeginningOfLine'
'Alt+E' = 'EndOfLine'
'Alt+D8' = 'DeleteBackwardChar'
'Backspace' = 'DeleteBackwardChar'
'Alt+D' = 'DeleteForwardChar'
'Delete' = 'DeleteForwardChar'
'Alt+U' = 'KillBeginningOfLine'
'Alt+K' = 'KillEndOfLine'
'Alt+R' = 'RotateMatcher'
'Alt+C' = 'ToggleCaseSensitive'
'Alt+I' = 'ToggleInvertFilter'
'Alt+W' = 'DeleteBackwardWord'
'Alt+N' = 'SelectUp'
'Alt+P' = 'SelectDown'
'Control+Spacebar' = 'ToggleSelectionAndSelectNext'
'UpArrow' = 'SelectUp'
'DownArrow' = 'SelectDown'
'RightArrow' = 'ScrollPageUp'
'LeftArrow' = 'ScrollPageDown'
'Tab' = 'TabExpansion'
}
}
function Get-PocoKey
{
$flag = [console]::TreatControlCAsInput
[console]::TreatControlCAsInput = $true
$Key = [console]::ReadKey($true)
[console]::TreatControlCAsInput = $flag
$KeyString = $Key.Key.ToString()
if ($Key.Modifiers -ne 0)
{
$m = $Key.Modifiers.ToString() -replace ', ', '+'
$KeyString = "${m}+${KeyString}"
}
return $Key, $KeyString
}
function Get-Action ($config, $keystr)
{
if ($config.Keymaps.Contains($keystr))
{
return $config.Keymaps[$keystr]
}
if ($keystr -notmatch 'Alt|Control')
{
'AddChar'
}
}
function Where-Query
{
Param
(
$state,
[Parameter(ValueFromPipeline=$True)]
$obj
)
begin {$hash = Convert-QueryHash $state}
process
{
if ($hash.Contains(''))
{
foreach ($value in $hash[''])
{
$test = Test-Matching $state.Screen.FilterType $obj $value
if ($test -eq $false) {return}
}
}
foreach ($property in $hash.Keys)
{
if ($property -eq '') {continue}
$l = ($obj | Get-Member $property).length
if ($l -eq 0) {continue}
foreach ($value in $hash[$property])
{
$test = Test-Matching $state.Screen.FilterType $obj.$property $value
if ($test -eq $false) {return}
}
}
,$obj
}
}
function Convert-QueryHash ($state)
{
$property = ''
$hash = @{$property = @()}
$state.Query -split ' ' | ?{$_ -ne ''} | %{
$token = $_
if ($token.StartsWith(':'))
{
$property = $token.Remove(0, 1)
if (-not $hash.Contains($property))
{
$hash[$property] = @()
}
}
else
{
$hash[$property] += $token
}
}
$hash
}
function Test-Matching
{
Param
(
[string] $FilterType,
[string] $p,
[string] $value
)
try
{
switch ($FilterType)
{
'match' {$p -match $value}
'like' {$p -like $value}
'eq' {$p -eq $value}
'notmatch' {$p -notmatch $value}
'notlike' {$p -notlike $value}
'neq' {$p -ne $value}
'cmatch' {$p -cmatch $value}
'clike' {$p -clike $value}
'ceq' {$p -ceq $value}
'cnotmatch' {$p -cnotmatch $value}
'cnotlike' {$p -cnotlike $value}
'cneq' {$p -cne $value}
}
}
catch
{
$true
}
}
# http://d.hatena.ne.jp/newpops/20080514
# スクリーンバッファのバックアップ
function Backup-ScrBuf
{
$rui = Get-RawUI
$rect = New-Object System.Management.Automation.Host.Rectangle
$rect.Left = 0
$rect.Top = 0
$rect.Right = $rui.WindowSize.Width
$rect.Bottom = $rui.CursorPosition.Y
$script:screen = $rui.GetBufferContents($rect)
}
# http://d.hatena.ne.jp/newpops/20080515
# スクリーンバッファのリストア
function Restore-ScrBuf
{
Clear-Host
$rui = Get-RawUI
if (-not (Test-Path 'variable:screen')) {return}
$origin = New-Object System.Management.Automation.Host.Coordinates(0, 0)
$rui.SetBufferContents($origin, $script:screen)
$pos = New-Object System.Management.Automation.Host.Coordinates(0, $script:screen.GetUpperBound(0))
$rui.CursorPosition = $pos
}
function Write-Screen ($state, $config)
{
switch ($config.Layout)
{
'TopDown' {Write-TopDown $state $config}
'BottomUp' {Write-BottomUp $state $config}
}
}
function Write-TopDown ($state, $config)
{
Write-ScreenLine 0 $state.Screen.Prompt
Write-RightInfo 0 $state
if ($state.Entry.length -ne $state.PrevLength)
{
$h = (Get-RawUI).WindowSize.Height
$entries = $state.Entry | Format-Table | Out-String -Stream | Select-Object -First ($h - 1)
if ($entries -is [string]) {$entries = ,@($entries)}
foreach ($i in 0..($h - 2))
{
$line = if ($i -lt $entries.length) {$entries[$i]} else {''}
Write-ScreenLine ($i + 1) $line
}
$state.PrevLength = $state.Entry.length
}
$x = Convert-CursorPositionX $state
Set-CursorPosition $x 0
}
function Write-BottomUp ($state, $config, $entries)
{
if ($state.Entry.length -ne $state.PrevLength)
{
if ($state.Screen.Page -gt $m) {$state.Screen.Page = $m}
$h = (Get-RawUI).WindowSize.Height
$entries = $state.Entry | Format-Table | Out-String -Stream | Select-Object -First ($h - 1)
if ($entries -is [string]) {$entries = ,@($entries)}
foreach ($i in 0..($h - 2))
{
$line = if ($i -lt $entries.length) {$entries[$i]} else {''}
Write-ScreenLine $i $line
}
$state.PrevLength = $state.Entry.length
}
Write-ScreenLine ($h - 1) $state.Screen.Prompt
Write-RightInfo ($h - 1) $state
$x = Convert-CursorPositionX $state
$y = (Get-RawUI).CursorPosition.Y
Set-CursorPosition $x $y
}
function Write-ScreenLine ($i, $line)
{
$w = (Get-RawUI).BufferSize.Width
Set-CursorPosition 0 $i
([string]$line).PadRight($w) | Write-Host -NoNewline
}
function Write-RightInfo ($i, $state)
{
$f = $state.Screen.FilterType
$n = $state.Entry.Length
$h = (Get-RawUI).WindowSize.Height
$info = "${f} [${n}]"
$w = (Get-RawUI).WindowSize.Width
Set-CursorPosition ($w - $info.length) $i
$info | Write-Host -NoNewline
}
function Convert-CursorPositionX ($state)
{
$str = $state.Screen.Prompt.Substring(0, $state.Screen.X)
(Get-RawUI).LengthInBufferCells($str)
}
function Set-CursorPosition ($x, $y)
{
$pos = New-Object System.Management.Automation.Host.Coordinates($x, $y)
(Get-RawUI).CursorPosition = $pos
}
function Update-State ($state, $config, $action, $key)
{
switch ($action)
{
'AddChar' {Add-Char $state $config $key.KeyChar}
'ForwardChar' {Move-ForwardChar $state}
'BackwardChar' {Move-BackwardChar $state}
'BeginningOfLine' {Move-BeginningOfLine $state}
'EndOfLine' {Move-EndOfLine $state}
'DeleteBackwardChar' {Remove-BackwardChar $state}
'DeleteForwardChar' {Remove-ForwardChar $state}
'KillBeginningOfLine' {Remove-HeadLine $state}
'KillEndOfLine' {Remove-TailLine $state}
'RotateMatcher' {Select-Matcher $state}
'ToggleCaseSensitive' {Switch-CaseSensitive $state}
'ToggleInvertFilter' {Switch-InvertFilter $state}
default {} # None, Cancel, Finish = identity
}
$state
}
function Add-Char ($state, $config, $char)
{
$x = $state.Screen.QueryX
$q = $state.Query
$state.Query = $q.Insert($x, $char)
$state.Screen.QueryX++
$state.Screen.X++
$state.Screen.Prompt = Get-Prompt $state $config
$state.Entry = Get-Entry $state $config
}
function Move-BackwardChar ($state)
{
$x = $state.Screen.QueryX
if ($x - 1 -ge 0)
{
$state.Screen.QueryX--
$state.Screen.X--
}
}
function Move-ForwardChar ($state)
{
$x = $state.Screen.X
$l = $state.Screen.Prompt.length
if ($x + 1 -le $l)
{
$state.Screen.QueryX++
$state.Screen.X++
}
}
function Move-BeginningOfLine ($state)
{
$state.Screen.X -= $state.Screen.QueryX
$state.Screen.QueryX = 0
}
function Move-EndOfLine ($state)
{
$state.Screen.QueryX = $state.Query.length
$state.Screen.X = $state.Screen.Prompt.length
}
function Remove-BackwardChar ($state)
{
$x = $state.Screen.QueryX
$q = $state.Query
if ($x - 1 -ge 0) {
$state.Query = $q.Remove($x - 1, 1)
$state.Screen.QueryX--
$state.Screen.X--
$state.Screen.Prompt = Get-Prompt $state $config
$state.Entry = Get-Entry $state $config
}
}
function Remove-ForwardChar ($state)
{
$x = $state.Screen.X
$l = $state.Screen.Prompt.length
$qx = $state.Screen.QueryX
$q = $state.Query
if ($x + 1 -le $l)
{
$state.Query = $q.Remove($qx, 1)
$state.Screen.Prompt = Get-Prompt $state $config
$state.Entry = Get-Entry $state $config
}
}
function Remove-HeadLine ($state)
{
while ($state.Screen.QueryX -gt 0)
{
Remove-BackwardChar ($state)
}
}
function Remove-TailLine ($state)
{
while ($state.Screen.QueryX -lt $state.Query.length)
{
Remove-ForwardChar ($state)
}
}
function Select-Matcher ($state)
{
$arr = @('match', 'like', 'eq')
$n = $arr.length
$i = $arr.IndexOf($state.Filter) + 1
$state.Filter = $arr[$i % $n]
$state.Screen.FilterType = Get-FilterType $state
$state.Entry = Get-Entry $state $config
}
function Switch-CaseSensitive ($state)
{
$state.CaseSensitive = -not $state.CaseSensitive
$state.Screen.FilterType = Get-FilterType $state
$state.Entry = Get-Entry $state $config
}
function Switch-InvertFilter ($state)
{
$state.InvertFilter = -not $state.InvertFilter
$state.Screen.FilterType = Get-FilterType $state
$state.Entry = Get-Entry $state $config
}
function Get-RawUI {(Get-Host).UI.RawUI}
function Get-Prompt ($state, $config)
{
$config.Prompt + '> ' + $state.Query
}
function Get-FilterType ($state)
{
$type = ''
if ($state.CaseSensitive) {$type += 'c'}
if ($state.InvertFilter) {$type += 'not'}
$type += $state.Filter
$type -replace 'noteq', 'neq'
}
function Get-Entry ($state, $config)
{
$config.Input |
Select-Object -Property $config.Property |
Where-Query $state
}
@karkianish
Copy link

Hi Yamura. Thank-you for sharing this code. Could you give me a bit of guidance on how to run this code locally please? I am new to powershell module development but not new to programming in general. I am really impressed with the way I can interactively filter in powershell with this module, so I was hoping to understand more and possibly extend in some other scenarios like command history search.

@jockrow
Copy link

jockrow commented May 7, 2021

Hi Yamura. Thank-you for sharing this code. Could you give me a bit of guidance on how to run this code locally please? I am new to powershell module development but not new to programming in general. I am really impressed with the way I can interactively filter in powershell with this module, so I was hoping to understand more and possibly extend in some other scenarios like command history search.

You have clone this repository:
git clone https://gist.github.com/yumura/8df37c22ae1b7942dec7
cd 8df37c22ae1b7942dec7

with Admin privileges
create folder Poco at modules for PowerShell
mkdir $PSHome/Modules/Poco
cd $PSHome/Modules/Poco

copy all content to modules folder:
copy * $PSHome/Modules/Poco

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment