Classes/ConsoleInput.psm1
using namespace System using namespace System.Runtime.InteropServices class ConsoleInputState { [int] $CursorPos = 0 [string] $Text = "" [string] $PreviousText = "" [int] $InitialCursorTop = 0 [int] $InitialCursorLeft = 0 ConsoleInputState() { $this.InitialCursorTop = [Console]::CursorTop $this.InitialCursorLeft = [Console]::CursorLeft } [int] WindowWidth() { return [Console]::BufferWidth } [int] WindowHeight() { return [Console]::BufferHeight } } class ConsoleInputExtension { [bool] ProcessKey([ConsoleInputState]$state, [ConsoleKeyInfo]$key) { return $false } } class ConsoleInputHistory : ConsoleInputExtension { [int] $MaxHistorySize = 100 [int] $CurrentHistoryIndex = 0 [string[]] $History = @() [bool] ProcessKey([ConsoleInputState]$state, [ConsoleKeyInfo]$key) { if($key.Key -eq [ConsoleKey]::UpArrow) { $state.Text = $this.GetPreviousHistory() return $true } elseif($key.Key -eq [ConsoleKey]::DownArrow) { $state.Text = $this.GetNextHistory() return $true } return $false } [string] GetPreviousHistory() { $this.CurrentHistoryIndex-- if($this.CurrentHistoryIndex -lt 0) { $this.CurrentHistoryIndex = 0 } return $this.History[$this.CurrentHistoryIndex] } [string] GetNextHistory() { if($this.CurrentHistoryIndex -eq $this.History.Count) { return "" } $this.CurrentHistoryIndex++ return $this.History[$this.CurrentHistoryIndex] } AddHistory([string]$text) { $this.History += $text if($this.History.Count -gt $this.MaxHistorySize) { $this.History = $this.History[1..$this.MaxHistorySize] } $this.CurrentHistoryIndex = $this.History.Count } } 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($state) } [int][ConsoleKey]::E = { $this.AltEnterBehavior = !$this.AltEnterBehavior } } static [string] Read() { $ci = [ConsoleInput]::new() return $ci.ReadLine() } [bool] IsMacOS() { return [RuntimeInformation]::IsOSPlatform([OSPlatform]::OSX) } [bool] IsWindows() { return [RuntimeInformation]::IsOSPlatform([OSPlatform]::Windows) } [bool] IsExitKey($state, $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] GetNavigationDelta($state, $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]::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($state, [int]$delta) { $state.CursorPos += $delta if($state.CursorPos -lt 0) { $state.CursorPos = 0 } if($state.CursorPos -gt $state.Text.Length) { $state.CursorPos = $state.Text.Length } $this.UpdateCursorPosition($state) } UpdateCursorPosition($state) { if($state.CursorPos -gt $state.Text.Length) { $state.CursorPos = $state.Text.Length } # calculate actual cursor pos when text includes newlines $leftText = $state.Text.Substring(0, $state.CursorPos) $left = $state.InitialCursorLeft $top = $state.InitialCursorTop foreach($char in $leftText.ToCharArray()) { if($char -eq "`n") { $top += 1 $left = 0 continue } $left += if($char -eq "`t") { 4 } else { 1 } if($left -ge $state.WindowWidth()) { $left = 0 $top += 1 } } [Console]::CursorTop = $top [Console]::CursorLeft = $left } RemoveCharacterLeft($state) { if($state.CursorPos -eq 0) { return } $state.Text = $state.Text.Substring(0, $state.CursorPos - 1) + $state.Text.Substring($state.CursorPos) $this.WriteText($state) } RemoveCharacterRight($state) { if($state.CursorPos -gt $state.Text.Length - 1) { return } $state.Text = $state.Text.Substring(0, $state.CursorPos) + $state.Text.Substring($state.CursorPos + 1) $this.WriteText($state) } InsertCharacter($state, $keyChar) { $state.Text = $state.Text.Substring(0, $state.CursorPos) + $keyChar + $state.Text.Substring($state.CursorPos) $state.CursorPos += 1 $this.WriteText($state) } UpdateDebugInfo($state, $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: $($state.WindowWidth()) $(" "*4)") [Console]::WriteLine("WindowHeight: $($state.WindowHeight()) $(" "*4)") [Console]::WriteLine($($state | ConvertTo-Json -Depth 10)) [Console]::WriteLine($($key | ConvertTo-Json -Depth 10)) $this.UpdateCursorPosition($state) } WriteText($state) { [Console]::CursorVisible = $false # write text while clearing unused space [Console]::CursorTop = $state.InitialCursorTop [Console]::CursorLeft = $state.InitialCursorLeft $top = $state.InitialCursorTop foreach($char in $state.Text.ToCharArray()) { if($char -eq "`n") { # clear rest of the line $top += 1 [Console]::Write(" " * ($state.WindowWidth() - [Console]::CursorLeft)) if($this.IsWindows()) { # todo: test for linux [Console]::Write("`n") } continue } [Console]::Write($char) if([Console]::CursorLeft -eq 0 -or [Console]::CursorTop -gt $top) { $top += 1 } } # clear final line [Console]::Write(" " * ($state.WindowWidth() - [Console]::CursorLeft)) $topAfter = [Console]::CursorTop # [Console]::Title = "top-calc: $top, top-curosr: $topAfter" if($top -eq $state.WindowHeight() -and $topAfter -eq $state.WindowHeight() - 1) { $state.InitialCursorTop -= 1 } $this.UpdateCursorPosition($state) $state.PreviousText = $state.Text [Console]::CursorVisible = $true } Paste($state) { $content = (Get-Clipboard -Raw | Select-Object -First 1) if(!$content) { return } $contentRemaining = $state.Text.Substring($state.CursorPos) $state.Text = $state.Text.Substring(0, $state.CursorPos) + $content + $contentRemaining $state.CursorPos += $content.Length $this.WriteText($state) $this.UpdateCursorPosition($state) } [string] ReadLine() { return $this.ReadLine("") } [string] ReadLine($prompt) { Write-Host $prompt -NoNewline $state = [ConsoleInputState]::new() [Console]::TreatControlCAsInput = $this.TreatControlCAsInput do { $key = [Console]::ReadKey($true) $this.Extensions | ForEach-Object { if($_.ProcessKey($state, $key)) { $this.WriteText($state) } } $this.UpdateDebugInfo($state, $key, "") # exit (based on ExitKeys property) if ($this.IsExitKey($state, $key)) { if($this.NewLineOnEnter) { [Console]::WriteLine() } break } # navigation (arrow keys) $delta = $this.GetNavigationDelta($state, $key) if($delta -ne 0) { $this.NavigateTextLeft($state, $delta) $this.UpdateDebugInfo($state, $key, "") continue } # deleting (backspace, delete) if ($key.Key -eq [ConsoleKey]::BackSpace) { $this.RemoveCharacterLeft($state) $this.UpdateDebugInfo($state, $key, "") # $this.NavigateTextLeft($state, -1) continue } if ($key.Key -eq [ConsoleKey]::Delete) { $this.RemoveCharacterRight($state) $this.UpdateDebugInfo($state, $key, "") continue } if ($key.Key -eq [ConsoleKey]::Escape) { $state.Text = "" $state.CursorPos = 0 $this.WriteText($state) 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($state, "`n") [Console]::CursorLeft = 0 continue } # ignored keys (alt, ctrl, control characters) if ($this.IsIgnored($key) -or [char]::IsControl($key.KeyChar)) { continue } $this.InsertCharacter($state, $key.KeyChar) $this.UpdateDebugInfo($state, $key, "") } while ($true) return $state.Text } } # Windows PSC: pwsh -Command $(Get-Content -Raw "./src/PsChat/Classes/ConsoleInput.psm1") # MacOS PSC: pwsh -nop ./src/PsChat/Classes/ConsoleInput.psm1 -Debug if($args -eq "-Debug") { 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): ") } |