Classes/Console/ConsoleInput.psm1
using module ".\ConsoleInputState.psm1" using module ".\ConsoleInputHistory.psm1" using namespace System using namespace System.Runtime.InteropServices class ConsoleInput { [bool] $Debug = $false [bool] $TreatControlCAsInput = $false [bool] $NewLineOnEnter = $true [ConsoleInputExtension[]] $Extensions = @() [ConsoleKey[]] $ExitKeys = @([ConsoleKey]::Enter, [ConsoleKey]::Escape) [bool] $AltEnterBehavior = $false [object] $ChordMap = @{ [int][ConsoleKey]::V = { $this.Paste() } [int][ConsoleKey]::E = { $this.AltEnterBehavior = !$this.AltEnterBehavior } } [ConsoleInputState] $State = [ConsoleInputState]::new() static [string] Read() { $ci = [ConsoleInput]::new() return $ci.ReadLine() } [bool] PsClassic() { return (Get-Host).Version.Major -lt 6 } [bool] IsMacOS() { if($this.PsClassic()) { return $false } return [RuntimeInformation]::IsOSPlatform([OSPlatform]::OSX) } [bool] IsWindows() { return [RuntimeInformation]::IsOSPlatform([OSPlatform]::Windows) } [bool] IsExitKey($key) { if($key.Modifiers -band [ConsoleModifiers]::Shift) { return $false } if($key.Key -eq [ConsoleKey]::Enter -and $this.AltEnterBehavior) { return $false } return ($this.ExitKeys -contains $key.Key) } [bool] IsIgnored($key) { return ` $key.Modifiers -band [ConsoleModifiers]::Alt -or ` $key.Modifiers -band [ConsoleModifiers]::Control } [bool] IsChord($key) { if(($key.KeyChar -eq "π" -and $this.IsMacOS())) { return $true } if($key.KeyChar -eq "p" -and $key.Modifiers -band [ConsoleModifiers]::Alt) { return $true } return $false } [int] GetCursorDiffVertical([int]$direction) { if($this.AltEnterBehavior -eq $false) { return 0 } # get distance to next newline in specified direction $delta = 0 $pos = $this.state.CursorPos $text = $this.state.Text while($pos -ge 0 -and $pos -le $text.Length) { if($text[$pos] -eq "`n") { $delta += $direction break } if([Math]::Abs($delta) -ge $this.state.WindowWidth()) { break } $pos += $direction $delta += $direction } return $delta } [int] GetNavigationDelta($key) { # todo: support for home and key-keys # note: for some reason, its required to cast the enum to int explicitly switch ([int]$key.Key) { ([int][ConsoleKey]::UpArrow) { return $this.GetCursorDiffVertical(-1) } ([int][ConsoleKey]::DownArrow) { return $this.GetCursorDiffVertical(1) } ([int][ConsoleKey]::LeftArrow) { if ($key.Modifiers -band [ConsoleModifiers]::Control) { return -10 } else { return -1 } } ([int][ConsoleKey]::RightArrow) { if ($key.Modifiers -band [ConsoleModifiers]::Control) { return 10 } else { return 1 } } # note: MacOS handles arrow keys combined with 'option' funny ([int][ConsoleKey]::B) { if ($this.IsMacOS() -and $key.Modifiers -band [ConsoleModifiers]::Alt) { return -10 } } ([int][ConsoleKey]::F) { if ($this.IsMacOS() -and $key.Modifiers -band [ConsoleModifiers]::Alt) { return 10 } } } return 0 } NavigateTextLeft([int]$delta) { $this.State.CursorPos += $delta if($this.State.CursorPos -lt 0) { $this.State.CursorPos = 0 } if($this.State.CursorPos -gt $this.State.Text.Length) { $this.State.CursorPos = $this.State.Text.Length } $this.UpdateCursorPosition() } UpdateCursorPosition() { if($this.state.CursorPos -gt $this.state.Text.Length) { $this.state.CursorPos = $this.state.Text.Length } $leftText = $this.state.Text.Substring(0, $this.state.CursorPos) $end = $this.CalculateTextEnd($this.state.InitialCursorLeft, $this.state.InitialCursorTop, $leftText) [Console]::CursorTop = $end.top [Console]::CursorLeft = $end.left } RemoveCharacterLeft() { if($this.state.CursorPos -eq 0) { return } $rest = $this.state.Text.Substring($this.state.CursorPos) $this.state.Text = $this.state.Text.Substring(0, $this.state.CursorPos - 1) + $rest $this.NavigateTextLeft(-1) $this.Update() } RemoveCharacterRight() { if($this.state.CursorPos -gt $this.state.Text.Length - 1) { return } $this.state.Text = $this.state.Text.Substring(0, $this.state.CursorPos) + $this.state.Text.Substring($this.state.CursorPos + 1) $this.Update() } InsertCharacter($keyChar) { $this.state.Text = $this.state.Text.Substring(0, $this.state.CursorPos) + $keyChar + $this.state.Text.Substring($this.state.CursorPos) $this.state.CursorPos += 1 $this.Update() } UpdateDebugInfo($key, $message="") { if(!$this.Debug) { return } [Console]::CursorTop = 5 [Console]::CursorLeft = 0 [Console]::WriteLine("Debug/Message: $message $(" "*40)") [Console]::WriteLine("Key: $($key.KeyChar) $(" "*4)") [Console]::WriteLine("KeyChar.IsControl: $([char]::IsControl($key.KeyChar)) $(" "*4)") [Console]::WriteLine("WindowWidth: $($this.state.WindowWidth()) $(" "*4)") [Console]::WriteLine("WindowHeight: $($this.state.WindowHeight()) $(" "*4)") [Console]::WriteLine($($this.state | ConvertTo-Json -Depth 10)) [Console]::WriteLine($($key | ConvertTo-Json -Depth 10)) $this.UpdateCursorPosition() } [object] CalculateTextEnd($initialLeft, $initialTop, $text) { $left = $initialLeft $top = $initialTop foreach($char in $text.ToCharArray()) { if($char -eq "`n") { $top += 1 $left = 0 continue } $left += if($char -eq "`t") { 4 } else { 1 } if($left -ge $this.state.WindowWidth()) { $left = 0 $top += 1 } } return @{ top=$top; left=$left } } Update() { [Console]::CursorVisible = $false # clear existing content $blankText = $this.state.PreviousText -replace '[^\n\t]', ' ' [Console]::CursorTop = $this.state.InitialCursorTop [Console]::CursorLeft = $this.state.InitialCursorLeft [Console]::Write($blankText) # debug background color $bgColor = [Console]::BackgroundColor # $this.Debug = $true if($this.Debug) { [Console]::BackgroundColor = [ConsoleColor]::DarkGray } # calculate actual space used by text $end = $this.CalculateTextEnd($this.state.InitialCursorLeft, $this.state.InitialCursorTop, $this.state.Text) $top = $end.top # if text is too long, scroll up if($top -gt $this.state.WindowHeight() - 1) { [Console]::CursorTop = $this.state.WindowHeight() - 1 [Console]::CursorLeft = 0 $delta = $top - $this.state.WindowHeight() + 1 [Console]::Write("`n" * $delta) $this.state.InitialCursorTop -= $delta } # write text while clearing unused space [Console]::CursorTop = $this.state.InitialCursorTop [Console]::CursorLeft = $this.state.InitialCursorLeft [Console]::Write($this.state.Text) [Console]::BackgroundColor = $bgColor $this.UpdateCursorPosition() $this.state.PreviousText = $this.state.Text [Console]::CursorVisible = $true } Paste() { $content = (Get-Clipboard -Raw | Select-Object -First 1) if(!$content) { return } $contentRemaining = $this.state.Text.Substring($this.state.CursorPos) $this.state.Text = $this.state.Text.Substring(0, $this.state.CursorPos) + $content + $contentRemaining $this.state.CursorPos += $content.Length $this.Update() $this.UpdateCursorPosition() } [string] ReadLine() { return $this.ReadLine("") } [string] ReadLine($prompt) { Write-Host $prompt -NoNewline $this.State = [ConsoleInputState]::new() [Console]::TreatControlCAsInput = $this.TreatControlCAsInput do { $key = [Console]::ReadKey($true) $this.Extensions | ForEach-Object { if($_.ProcessKey($this, $key)) { $this.Update() } } $this.UpdateDebugInfo($key, "") # exit (based on ExitKeys property) if ($this.IsExitKey($key)) { if($this.NewLineOnEnter) { [Console]::WriteLine() } break } # navigation (arrow keys) $delta = $this.GetNavigationDelta($key) if($delta -ne 0) { $this.NavigateTextLeft($delta) $this.UpdateDebugInfo($key, "") continue } # deleting (backspace, delete) if ($key.Key -eq [ConsoleKey]::BackSpace) { $this.RemoveCharacterLeft() continue } if ($key.Key -eq [ConsoleKey]::Delete) { $this.RemoveCharacterRight() $this.UpdateDebugInfo($key, "") continue } if ($key.Key -eq [ConsoleKey]::Escape) { $this.State.Text = "" $this.State.CursorPos = 0 $this.Update() continue } # chords allow for alt-p + v to paste if($this.IsChord($key)) { $title = "" if($this.IsWindows()) { $title = [Console]::Title $chords = $this.ChordMap.Keys | ForEach-Object { [char]$_ } [Console]::Title = "Chord mode activated. Available chords: $($chords -join ", ")" } $map = $this.ChordMap $key = [int][Console]::ReadKey($true).Key if($this.IsWindows()) { [Console]::Title = $title } if($map[$key]) { $map[$key].Invoke() } continue } # if alternative enter behavior is enabled (or shift is down on pc), enter will insert a newline if (($this.AltEnterBehavior -or $key.Modifiers -band [ConsoleModifiers]::Shift) -and $key.Key -eq [ConsoleKey]::Enter) { $this.InsertCharacter("`n") [Console]::CursorLeft = 0 continue } # ignored keys (alt, ctrl, control characters) if ($this.IsIgnored($key) -or [char]::IsControl($key.KeyChar)) { continue } $this.InsertCharacter($key.KeyChar) $this.UpdateDebugInfo($key, "") } while ($true) return $this.State.Text } } # poor mans testing: # pushd ".\src\PsChat\Classes\Console\"; Invoke-Expression (Get-ChildItem -Path '.' -Filter '*.psm1' | ForEach-Object { Get-Content -Raw $_.FullName } | % {$_.replace("condbg","true")} | Out-String); popd if($condbg) { clear # Write-Host "`n" * 20 # Write-Host $args [Console]::Write("`n" * 30) $input = [ConsoleInput]::new() $input.Debug = $true $input.AltEnterBehavior = $true $input.ReadLine("Enter text $(Get-Date): ") } |