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 = @()

    static [string] Read() {
        $ci = [ConsoleInput]::new()
        return $ci.ReadLine()
    }

    [bool] IsMacOS() {
        return [RuntimeInformation]::IsOSPlatform([OSPlatform]::OSX)
    }

    [bool] IsExitKey($key) {
# return ($key.Key -eq [ConsoleKey]::Escape -or $key.Key -eq [ConsoleKey]::Enter)
        return ($key.Key -eq [ConsoleKey]::Enter)
    }

    [bool] IsIgnored($key) {
        return `
            $key.Modifiers -band [ConsoleModifiers]::Alt -or `
            $key.Modifiers -band [ConsoleModifiers]::Control
    }

    [int] GetNavigationDelta($state, $key) {
        # 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) {
        $currentLeft = $state.InitialCursorLeft + $state.CursorPos
        $topDelta = [Math]::Floor($currentLeft / $state.WindowWidth())
        $leftDelta = $currentLeft % $state.WindowWidth()
        $top = $state.InitialCursorTop + $topDelta
        if($top -ge $state.WindowHeight()) {
            [Console]::Write("`n")
            $state.InitialCursorTop -= 1
            $top = $state.WindowHeight() -1 
        }
        [Console]::CursorTop = $top
        [Console]::CursorLeft = $leftDelta
    }

    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")
        [Console]::WriteLine("Key: $($key.KeyChar)")
        [Console]::WriteLine("KeyChar.IsControl: $([char]::IsControl($key.KeyChar))")
        [Console]::WriteLine("WindowWidth: $($state.WindowWidth())")
        [Console]::WriteLine("WindowHeight: $($state.WindowHeight())")
        [Console]::WriteLine($($state | ConvertTo-Json -Depth 10))
        [Console]::WriteLine($($key | ConvertTo-Json -Depth 10))
        $this.UpdateCursorPosition($state)
    }

    WriteText($state) {
        [Console]::CursorTop = $state.InitialCursorTop
        [Console]::CursorLeft = $state.InitialCursorLeft
        [Console]::Write("$($state.Text)")

        # overwrite remaining characters with spaces
        $shorter = $state.PreviousText.Length - $state.Text.Length
        if($shorter -gt 0) {
            [Console]::Write(" " * $shorter)
        }

        $this.UpdateCursorPosition($state)
        $state.PreviousText = $state.Text
    }

    [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 (enter or escape)
            if ($this.IsExitKey($key)) {
                if($this.NewLineOnEnter) {
                    [Console]::WriteLine()
                }
                break
            }

            # navigation (arrow keys)
            $delta = $this.GetNavigationDelta($state, $key)
            if($delta -ne 0) {
                $this.NavigateTextLeft($state, $delta)
                continue
            }

            # deleting (backspace, delete)
            if ($key.Key -eq [ConsoleKey]::BackSpace) {
                $this.RemoveCharacterLeft($state)
                $this.NavigateTextLeft($state, -1)
                continue
            }

            if ($key.Key -eq [ConsoleKey]::Delete) {
                $this.RemoveCharacterRight($state)
                continue
            }

            if ($key.Key -eq [ConsoleKey]::Escape) {
                $state.Text = ""
                $state.CursorPos = 0
                $this.WriteText($state)
                continue
            }

            # ignored keys (alt, ctrl, control characters)
            if ($this.IsIgnored($key) -or [char]::IsControl($key.KeyChar)) {
                continue
            }

            $this.InsertCharacter($state, $key.KeyChar)

        } while ($true)

        return $state.Text
    }
}

# $input = [ConsoleInput]::new()
# $input.Debug = $true
# $input.ReadLine("Enter text: ")