#! /usr/bin/python3
#
# Tow Column Virtual Terminal.
#
# Copyright 2011-2025 Helmut Grohne <helmut@subdivi.de>. All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
# 
#    1. Redistributions of source code must retain the above copyright notice,
#       this list of conditions and the following disclaimer.
# 
#    2. Redistributions in binary form must reproduce the above copyright
#       notice, this list of conditions and the following disclaimer in the
#       documentation and/or other materials provided with the distribution.
# 
# THIS SOFTWARE IS PROVIDED BY HELMUT GROHNE ``AS IS'' AND ANY EXPRESS OR
# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
# EVENT SHALL HELMUT GROHNE OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA,
# OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
# EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
# 
# The views and conclusions contained in the software and documentation are
# those of the authors and should not be interpreted as representing official
# policies, either expressed or implied, of Helmut Grohne.

import collections.abc
import contextlib
import functools
import locale
import pty
import sys
import os
import select
import fcntl
import termios
import struct
import curses
import errno
import time
import optparse
import typing


def init_color_pairs() -> None:
    for bi, bc in enumerate((curses.COLOR_BLACK, curses.COLOR_RED,
                             curses.COLOR_GREEN, curses.COLOR_YELLOW,
                             curses.COLOR_BLUE, curses.COLOR_MAGENTA,
                             curses.COLOR_CYAN, curses.COLOR_WHITE)):
        for fi, fc in enumerate((curses.COLOR_WHITE, curses.COLOR_BLACK,
                                 curses.COLOR_RED, curses.COLOR_GREEN,
                                 curses.COLOR_YELLOW, curses.COLOR_BLUE,
                                 curses.COLOR_MAGENTA, curses.COLOR_CYAN)):
            if fi != 0 or bi != 0:
                curses.init_pair(fi*8+bi, fc, bc)


def get_color(fg: int = 1, bg: int = 0) -> int:
    return curses.color_pair(((fg + 1) % 8) * 8 + bg)

class Simple:
    def __init__(self, curseswindow: curses.window):
        self.screen = curseswindow
        self.screen.scrollok(True)

    def getmaxyx(self) -> tuple[int, int]:
        return self.screen.getmaxyx()

    def move(self, ypos: int, xpos: int) -> None:
        ym, xm = self.getmaxyx()
        self.screen.move(max(0, min(ym - 1, ypos)), max(0, min(xm - 1, xpos)))

    def relmove(self, yoff: int, xoff: int) -> None:
        y, x = self.getyx()
        self.move(y + yoff, x + xoff)

    def addch(self, char: int) -> None:
        self.screen.addch(char)

    def refresh(self) -> None:
        self.screen.refresh()

    def getyx(self) -> tuple[int, int]:
        return self.screen.getyx()

    def scroll(self) -> None:
        self.screen.scroll()

    def clrtobot(self) -> None:
        self.screen.clrtobot()

    def attron(self, attr: int) -> None:
        self.screen.attron(attr)

    def attroff(self, attr: int) -> None:
        self.screen.attroff(attr)

    def clrtoeol(self) -> None:
        self.screen.clrtoeol()

    def delch(self) -> None:
        self.screen.delch()

    def attrset(self, attr: int) -> None:
        self.screen.attrset(attr)

    def insertln(self) -> None:
        self.screen.insertln()

    def insch(self, char: int) -> None:
        self.screen.insch(char)

    def deleteln(self) -> None:
        self.screen.deleteln()

class BadWidth(Exception):
    pass

class Columns:
    def __init__(
        self,
        curseswindow: curses.window,
        numcolumns: int = 2,
        reverse: bool = False,
    ):
        self.screen = curseswindow
        self.height, width = self.screen.getmaxyx()
        if numcolumns < 1:
            raise BadWidth("need at least two columns")
        self.numcolumns = numcolumns
        self.columnwidth = (width - (numcolumns - 1)) // numcolumns
        if self.columnwidth <= 0:
            raise BadWidth("resulting column width too small")
        self.windows: list[curses.window] = []
        for i in range(numcolumns):
            window = self.screen.derwin(self.height, self.columnwidth,
                                        0, i * (self.columnwidth + 1))
            window.scrollok(True)
            self.windows.append(window)
        if reverse:
            self.windows.reverse()
        self.ypos, self.xpos = 0, 0
        for i in range(1, numcolumns):
            self.screen.vline(0, i * (self.columnwidth + 1) - 1,
                              curses.ACS_VLINE, self.height)
        self.attrs = 0

    @property
    def curwin(self) -> curses.window:
        return self.windows[self.ypos // self.height]

    @property
    def curypos(self) -> int:
        return self.ypos % self.height

    @property
    def curxpos(self) -> int:
        return self.xpos

    def getmaxyx(self) -> tuple[int, int]:
        return (self.height * self.numcolumns, self.columnwidth)

    def move(self, ypos: int, xpos: int) -> None:
        height, width = self.getmaxyx()
        self.ypos = max(0, min(height - 1, ypos))
        self.xpos = max(0, min(width - 1, xpos))
        self.fix_cursor()

    def fix_cursor(self) -> None:
        self.curwin.move(self.curypos, self.curxpos)

    def relmove(self, yoff: int, xoff: int) -> None:
        self.move(self.ypos + yoff, self.xpos + xoff)

    def addch(self, char: int) -> None:
        if self.xpos == self.columnwidth - 1:
            if self.ypos + 1 == self.numcolumns * self.height:
                # disable scrolling for the addch call
                self.curwin.scrollok(True)
                try:
                    self.curwin.addch(self.curypos, self.curxpos, char,
                                      self.attrs)
                except curses.error:
                    # It errors out, but still draws the character.
                    # http://stackoverflow.com/a/41923640/1626632
                    pass
                self.curwin.scrollok(True)
                self.scroll()
                self.move(self.ypos, 0)
            else:
                self.curwin.addch(self.curypos, self.curxpos, char, self.attrs)
                self.move(self.ypos + 1, 0)
        else:
            self.curwin.addch(self.curypos, self.curxpos, char, self.attrs)
            self.xpos += 1

    def refresh(self) -> None:
        self.screen.refresh()
        for window in self.windows:
            if window is not self.curwin:
                window.refresh()
        self.curwin.refresh()

    def getyx(self) -> tuple[int, int]:
        return (self.ypos, self.xpos)

    def scroll_up(self, index: int) -> None:
        """Copy first line of the window with given index to last line of the
        previous window and scroll up the given window."""
        assert index > 0
        previous = self.windows[index - 1]
        previous.move(self.height - 1, 0)
        for x in range(self.columnwidth - 1):
            previous.addch(self.windows[index].inch(0, x))
        previous.insch(self.windows[index].inch(0, self.columnwidth - 1))
        self.fix_cursor()
        self.windows[index].scroll()

    def scroll_down(self, index: int) -> None:
        """Scroll down the window with given index and copy the last line of
        the previous window to the first line of the given window."""
        assert index > 0
        current = self.windows[index]
        previous = self.windows[index - 1]
        current.scroll(-1)
        current.move(0, 0)
        for x in range(self.columnwidth - 1):
            current.addch(previous.inch(self.height - 1, x))
        current.insch(previous.inch(self.height - 1, self.columnwidth - 1))
        self.fix_cursor()

    def scroll(self) -> None:
        self.windows[0].scroll()
        for i in range(1, self.numcolumns):
            self.scroll_up(i)

    def clrtobot(self) -> None:
        index = self.ypos // self.height
        for i in range(index + 1, self.numcolumns):
            self.windows[i].clear()
        self.windows[index].clrtobot()

    def attron(self, attr: int) -> None:
        self.attrs |= attr

    def attroff(self, attr: int) -> None:
        self.attrs &= ~attr

    def clrtoeol(self) -> None:
        self.curwin.clrtoeol()

    def delch(self) -> None:
        self.curwin.delch(self.curypos, self.curxpos)

    def attrset(self, attr: int) -> None:
        self.attrs = attr

    def insertln(self) -> None:
        index = self.ypos // self.height
        for i in reversed(range(index + 1, self.numcolumns)):
            self.scroll_down(i)
        self.curwin.insertln()

    def insch(self, char: int) -> None:
        self.curwin.insch(self.curypos, self.curxpos, char, self.attrs)

    def deleteln(self) -> None:
        index = self.ypos // self.height
        self.windows[index].deleteln()
        for i in range(index + 1, self.numcolumns):
            self.scroll_up(i)


def acs_map() -> dict[int, int]:
    """call after curses.initscr"""
    # can this mapping be obtained from curses?
    return {
        ord(b'l'): curses.ACS_ULCORNER,
        ord(b'm'): curses.ACS_LLCORNER,
        ord(b'k'): curses.ACS_URCORNER,
        ord(b'j'): curses.ACS_LRCORNER,
        ord(b't'): curses.ACS_LTEE,
        ord(b'u'): curses.ACS_RTEE,
        ord(b'v'): curses.ACS_BTEE,
        ord(b'w'): curses.ACS_TTEE,
        ord(b'q'): curses.ACS_HLINE,
        ord(b'x'): curses.ACS_VLINE,
        ord(b'n'): curses.ACS_PLUS,
        ord(b'o'): curses.ACS_S1,
        ord(b's'): curses.ACS_S9,
        ord(b'`'): curses.ACS_DIAMOND,
        ord(b'a'): curses.ACS_CKBOARD,
        ord(b'f'): curses.ACS_DEGREE,
        ord(b'g'): curses.ACS_PLMINUS,
        ord(b'~'): curses.ACS_BULLET,
        ord(b','): curses.ACS_LARROW,
        ord(b'+'): curses.ACS_RARROW,
        ord(b'.'): curses.ACS_DARROW,
        ord(b'-'): curses.ACS_UARROW,
        ord(b'h'): curses.ACS_BOARD,
        ord(b'i'): curses.ACS_LANTERN,
        ord(b'p'): curses.ACS_S3,
        ord(b'r'): curses.ACS_S7,
        ord(b'y'): curses.ACS_LEQUAL,
        ord(b'z'): curses.ACS_GEQUAL,
        ord(b'{'): curses.ACS_PI,
        ord(b'|'): curses.ACS_NEQUAL,
        ord(b'}'): curses.ACS_STERLING,
    }


def compose_dicts(
    dct1: dict[int, int], dct2: dict[int, int]
) -> dict[int, int]:
    result = {}
    for key, value in dct1.items():
        try:
            result[key] = dct2[value]
        except KeyError:
            pass
    return result


simple_low_characters = (
    b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
    b'0123456789@:~$ .#!/_(),[]=-+*\'"|<>%&\\?;`^{}'
)


simple_characters = (
    simple_low_characters
    + b'\xb4\xb6\xb7\xc3\xc4\xd6\xdc\xe4\xe9\xfc\xf6'
)


class Terminal:
    def __init__(
        self,
        acsc: dict[int, int],
        screenfactory: collections.abc.Callable[[curses.window], Columns],
    ):
        self.feed: collections.abc.Callable[
            [int], bytes | None
        ] = self.feed_simple
        self.realscreen: curses.window | None = None
        self.screen: Simple | Columns | None = None
        self.fg = self.bg = 0
        self.graphics_font = False
        self.graphics_chars = acsc # really initialized in __enter__
        self.lastchar = ord(b' ')
        self.screenfactory = screenfactory
        self.need_refresh: float | None = None

    def makescreen(self, switch: bool = False) -> None:
        assert self.realscreen is not None
        try:
            if isinstance(self.screen, Simple) ^ switch:
                raise BadWidth("use simple screen")
            self.screen = self.screenfactory(self.realscreen)
        except BadWidth:
            self.screen = Simple(self.realscreen)
        self.request_refresh()

    def switchmode(self) -> None:
        self.makescreen(True)

    def resized(self) -> None:
        assert self.realscreen is not None
        # The refresh call causes curses to notice the new dimensions.
        self.realscreen.refresh()
        self.realscreen.clear()
        self.makescreen()

    def resizepty(self, ptyfd: int) -> None:
        assert self.screen is not None
        ym, xm = self.screen.getmaxyx()
        fcntl.ioctl(ptyfd, termios.TIOCSWINSZ,
                    struct.pack("HHHH", ym, xm, 0, 0))

    def refresh_needed(self) -> bool:
        return self.need_refresh is not None

    def request_refresh(self) -> None:
        if self.need_refresh is None:
            self.need_refresh = time.time()

    def refresh(self, minwait: float = 0) -> None:
        if self.need_refresh is None:
            return
        if minwait > 0 and self.need_refresh + minwait > time.time():
            return
        assert self.screen is not None
        self.screen.refresh()
        self.need_refresh = None

    def addch(self, char: int) -> None:
        assert self.screen is not None
        self.lastchar = char
        self.screen.addch(char)
        self.request_refresh()

    def __enter__(self) -> "Terminal":
        assert self.realscreen is None
        self.realscreen = curses.initscr()
        self.realscreen.nodelay(True)
        self.realscreen.keypad(True)
        curses.start_color()
        init_color_pairs()
        self.makescreen()
        curses.noecho()
        curses.nonl()
        curses.raw()
        self.graphics_chars = compose_dicts(self.graphics_chars, acs_map())
        return self

    def __exit__(self, *_: typing.Any) -> None:
        curses.noraw()
        curses.nl()
        curses.echo()
        curses.endwin()
        self.screen = None
        self.realscreen = None

    def do_bel(self) -> None:
        curses.beep()

    def do_blink(self) -> None:
        assert self.screen is not None
        self.screen.attron(curses.A_BLINK)

    def do_bold(self) -> None:
        assert self.screen is not None
        self.screen.attron(curses.A_BOLD)

    def do_cr(self) -> None:
        assert self.screen is not None
        self.screen.relmove(0, -9999)
        self.request_refresh()

    def do_cub(self, n: int) -> None:
        assert self.screen is not None
        self.screen.relmove(0, -n)
        self.request_refresh()

    def do_cub1(self) -> None:
        self.do_cub(1)

    def do_cud(self, n: int) -> None:
        assert self.screen is not None
        self.screen.relmove(n, 0)
        self.request_refresh()

    def do_cud1(self) -> None:
        self.do_cud(1)

    def do_cuf(self, n: int) -> None:
        assert self.screen is not None
        self.screen.relmove(0, n)
        self.request_refresh()

    def do_cuf1(self) -> None:
        self.do_cuf(1)

    def do_cuu(self, n: int) -> None:
        assert self.screen is not None
        self.screen.relmove(-n, 0)
        self.request_refresh()

    def do_cuu1(self) -> None:
        self.do_cuu(1)

    def do_dch(self, n: int) -> None:
        assert self.screen is not None
        for _ in range(n):
            self.screen.delch()
            self.request_refresh()

    def do_dch1(self) -> None:
        self.do_dch(1)

    def do_dl(self, n: int) -> None:
        assert self.screen is not None
        for _ in range(n):
            self.screen.deleteln()
            self.request_refresh()

    def do_dl1(self) -> None:
        self.do_dl(1)

    def do_ech(self, n: int) -> None:
        assert self.screen is not None
        for _ in range(n):
            self.screen.addch(ord(b' '))
            self.request_refresh()

    def do_ed(self) -> None:
        assert self.screen is not None
        self.screen.clrtobot()
        self.request_refresh()

    def do_el0(self) -> None:
        assert self.screen is not None
        self.screen.clrtoeol()
        self.request_refresh()

    do_el = do_el0

    def do_el1(self) -> None:
        assert self.screen is not None
        y, x = self.screen.getyx()
        self.screen.move(y, 0)
        for _ in range(x + 1):
            self.screen.addch(ord(b' '))
        self.screen.move(y, x)
        self.request_refresh()

    def do_el2(self) -> None:
        assert self.screen is not None
        y, x = self.screen.getyx()
        self.screen.move(y, 0)
        self.screen.clrtoeol()
        self.screen.move(y, x)
        self.request_refresh()

    def do_home(self) -> None:
        assert self.screen is not None
        self.screen.move(0, 0)
        self.request_refresh()

    def do_hpa(self, n: int) -> None:
        assert self.screen is not None
        y, _ = self.screen.getyx()
        self.screen.move(y, n - 1)
        self.request_refresh()

    def do_ht(self) -> None:
        assert self.screen is not None
        y, x = self.screen.getyx()
        _, xm = self.screen.getmaxyx()
        x = min(x + 8 - x % 8, xm - 1)
        self.screen.move(y, x)
        self.request_refresh()

    def do_ich(self, n: int) -> None:
        assert self.screen is not None
        for _ in range(n):
            self.screen.insch(ord(b' '))
            self.request_refresh()

    def do_il(self, n: int) -> None:
        assert self.screen is not None
        for _ in range(n):
            self.screen.insertln()
            self.request_refresh()

    def do_il1(self) -> None:
        self.do_il(1)

    def do_ind(self) -> None:
        assert self.screen is not None
        y, _ = self.screen.getyx()
        ym, _ = self.screen.getmaxyx()
        if y + 1 == ym:
            self.screen.scroll()
            self.screen.move(y, 0)
        else:
            self.screen.move(y+1, 0)
        self.request_refresh()

    def do_invis(self) -> None:
        assert self.screen is not None
        self.screen.attron(curses.A_INVIS)

    def do_rep(self, n: int) -> None:
        assert self.screen is not None
        for _ in range(n):
            self.screen.addch(self.lastchar)
            self.request_refresh()

    def do_rmacs(self) -> None:
        self.graphics_font = False
        self.feed_reset()

    def do_smacs(self) -> None:
        self.graphics_font = True
        self.feed_reset()

    def do_smso(self) -> None:
        assert self.screen is not None
        self.screen.attron(curses.A_REVERSE)

    def do_smul(self) -> None:
        assert self.screen is not None
        self.screen.attron(curses.A_UNDERLINE)

    def do_vpa(self, n: int) -> None:
        assert self.screen is not None
        _, x = self.screen.getyx()
        self.screen.move(n - 1, x)
        self.request_refresh()

    def do_u7(self) -> bytes:
        assert self.screen is not None
        y, x = self.screen.getyx()
        return b"\x1b[%d;%dR" % (y + 1, x + 1)

    def feed_reset(self) -> None:
        if self.graphics_font:
            self.feed = self.feed_graphics
        else:
            self.feed = self.feed_simple

    feed_simple_table = {
        ord('\a'): do_bel,
        ord('\b'): do_cub1,
        ord('\n'): do_ind,
        ord('\r'): do_cr,
        ord('\t'): do_ht,
    }

    def feed_simple(self, char: int) -> None:
        func = self.feed_simple_table.get(char)
        if func:
            func(self)
        elif char in simple_characters:
            self.addch(char)
        elif char == 0x1b:
            self.feed = self.feed_esc
        else:
            raise ValueError("feed %r" % char)

    def feed_graphics(self, char: int) -> None:
        if char == 0x1b:
            self.feed = self.feed_esc
        elif char in self.graphics_chars:
            self.addch(self.graphics_chars[char])
        elif char == ord(b'q'):  # some applications appear to use VT100 names?
            self.addch(curses.ACS_HLINE)
        else:
            raise ValueError("graphics %r" % char)

    def feed_esc(self, char: int) -> None:
        if char == ord(b'['):
            self.feed = self.feed_esc_opbr
        elif char == ord(b']'):
            self.feed = functools.partial(self.feed_esc_clbr, prev=b"")
        else:
            raise ValueError("feed esc %r" % char)

    feed_esc_opbr_table = {
        ord('A'): do_cuu1,
        ord('B'): do_cud1,
        ord('C'): do_cuf1,
        ord('D'): do_cub1,
        ord('H'): do_home,
        ord('J'): do_ed,
        ord('L'): do_il1,
        ord('M'): do_dl1,
        ord('K'): do_el,
        ord('P'): do_dch1,
    }

    def feed_esc_opbr(self, char: int) -> None:
        self.feed_reset()
        func = self.feed_esc_opbr_table.get(char)
        if func:
            func(self)
        elif char == ord(b'm'):
            self.feed_esc_opbr_next(char, b'0')
        elif char == ord(b'?'):
            self.feed = functools.partial(
                self.feed_esc_opbr_quest_next, prev=b""
            )
        elif char in b'0123456789':
            self.feed = functools.partial(
                self.feed_esc_opbr_next, prev=bytes((char,))
            )
        else:
            raise ValueError("feed esc [ %r" % char)

    feed_color_table = {
        1: do_bold,
        4: do_smul,
        5: do_blink,
        7: do_smso,
        8: do_invis,
        10: do_rmacs,
        11: do_smacs,
    }

    def feed_color(self, code: int) -> None:
        assert self.screen is not None
        func = self.feed_color_table.get(code)
        if func:
            func(self)
        elif code == 0:
            self.fg = self.bg = 0
            self.screen.attrset(0)
        elif 30 <= code <= 37:
            self.fg = code - 30
            self.screen.attroff(curses.A_COLOR)
            self.screen.attron(get_color(self.fg, self.bg))
        elif code == 39:
            self.fg = 7
            self.screen.attroff(curses.A_COLOR)
            self.screen.attron(get_color(self.fg, self.bg))
        elif 40 <= code <= 47:
            self.bg = code - 40
            self.screen.attroff(curses.A_COLOR)
            self.screen.attron(get_color(self.fg, self.bg))
        elif code == 49:
            self.bg = 0
            self.screen.attroff(curses.A_COLOR)
            self.screen.attron(get_color(self.fg, self.bg))
        else:
            raise ValueError("feed esc [ %r m" % code)

    def feed_esc_opbr_quest_next(self, char: int, prev: bytes) -> None:
        self.feed_reset()
        if char in b'0123456789':
            self.feed = functools.partial(
                self.feed_esc_opbr_quest_next, prev=prev + bytes((char,))
            )
        elif char == ord('h') and int(prev) == 2004:
            pass # ignore enabling bracketed paste mode for now
        elif char == ord('l') and int(prev) == 2004:
            pass # ignore disabling bracketed paste mode for now
        else:
            raise ValueError("feed esc [ ? %r %r" % (prev, char))

    feed_esc_opbr_next_table = {
        ord('A'): do_cuu,
        ord('B'): do_cud,
        ord('C'): do_cuf,
        ord('D'): do_cub,
        ord('G'): do_hpa,
        ord('L'): do_il,
        ord('M'): do_dl,
        ord('P'): do_dch,
        ord('X'): do_ech,
        ord('@'): do_ich,
        ord('b'): do_rep,
        ord('d'): do_vpa,
    }

    def feed_esc_opbr_next(self, char: int, prev: bytes) -> bytes | None:
        assert self.screen is not None
        self.feed_reset()
        func = self.feed_esc_opbr_next_table.get(char)
        if func and prev.isdigit():
            func(self, int(prev))
        elif char in b'0123456789;':
            self.feed = functools.partial(
                self.feed_esc_opbr_next, prev=prev + bytes((char,))
            )
        elif char == ord(b'm'):
            parts = prev.split(b';')
            for p in parts:
                self.feed_color(int(p))
        elif char == ord(b'H'):
            parts = prev.split(b';')
            if len(parts) != 2:
                raise ValueError("feed esc [ %r H" % parts)
            self.screen.move(*map((-1).__add__, map(int, parts)))
            self.request_refresh()
        elif prev == b'2' and char == ord(b'J'):
            self.screen.move(0, 0)
            self.screen.clrtobot()
            self.request_refresh()
        elif char == ord(b'K') and prev == b'0':
            self.do_el0()
        elif char == ord(b'K') and prev == b'1':
            self.do_el1()
        elif char == ord(b'K') and prev == b'2':
            self.do_el2()
        elif char == ord(b'n') and prev == b'6':
            return self.do_u7()
        else:
            raise ValueError("feed esc [ %r %r" % (prev, char))
        return None

    def feed_esc_clbr(self, char: int, prev: bytes) -> None:
        self.feed_reset()
        if char == 7:
            # Bell character, end of control sequence; pass it through
            # if one of those that do not interfere with the other curses.
            if not prev.startswith((b"0;", b"1;", b"2;")):
                raise ValueError("dropped osc sequence: esc ] %r bel"
                                 % (prev,))
            self.refresh()
            stdout = sys.stdout.buffer
            stdout.write(b"\x1b]" + prev + b"\x07")
            stdout.flush()

        elif 8 <= char <= 13 or 32 <= char <= 126:
            self.feed = functools.partial(
                self.feed_esc_clbr, prev=prev + bytes((char,))
            )
        else:
            raise ValueError("feed esc ] %r %r" % (prev, char))

class UTF8Terminal(Terminal):
    def feed_simple(self, char: int) -> None:
        func = self.feed_simple_table.get(char)
        if func:
            func(self)
        elif char & 0b11000000 == 0b11000000:
            self.feed = functools.partial(
                self.feed_utf8, prev=bytes((char,))
            )
        elif char in simple_low_characters:
            self.addch(char)
        elif char == 0x1b:
            self.feed = self.feed_esc
        else:
            raise ValueError("feed %r" % char)

    def feed_utf8(self, char: int, prev: bytes) -> None:
        if char & 0b11000000 != 0b10000000:
            raise ValueError("invalid utf8 sequence")
        prev += bytes((char,))
        if len(prev) >= 8 - ((prev[0] | 0b11) ^ 0b11111100).bit_length():
            utf8char = prev.decode("utf8")
            assert len(utf8char) == 1
            self.addch(ord(utf8char))
            self.feed_reset()
        else:
            self.feed = functools.partial(self.feed_utf8, prev=prev)

symbolic_keymapping = {
    ord(b"\n"): "ind",
    ord(b"\r"): "cr",
    curses.KEY_LEFT: "kcub1",
    curses.KEY_DOWN: "kcud1",
    curses.KEY_RIGHT: "kcuf1",
    curses.KEY_UP: "kcuu1",
    curses.KEY_HOME: "khome",
    curses.KEY_IC: "kich1",
    curses.KEY_BACKSPACE: "kbs",
    curses.KEY_PPAGE: "kpp",
    curses.KEY_NPAGE: "knp",
    curses.KEY_F1: "kf1",
    curses.KEY_F2: "kf2",
    curses.KEY_F3: "kf3",
    curses.KEY_F4: "kf4",
    curses.KEY_F5: "kf5",
    curses.KEY_F6: "kf6",
    curses.KEY_F7: "kf7",
    curses.KEY_F8: "kf8",
    curses.KEY_F9: "kf9",
}


@contextlib.contextmanager
def terminal_changed_to(termname: str) -> collections.abc.Iterator[None]:
    oldterm = os.environ["TERM"]
    try:
        curses.setupterm(termname)
        yield
    finally:
        curses.setupterm(oldterm)


def compute_keymap(
    symbolic_map: dict[int, str]
) -> tuple[dict[int, bytes], dict[int, int]]:
    with terminal_changed_to("ansi"):
        keymap = {}
        for key, value in symbolic_map.items():
            keymap[key] = (curses.tigetstr(value) or b"").replace(b"\\E", b"\x1b")
        acsc = curses.tigetstr("acsc")
        assert acsc is not None
        acscmap = dict(zip(acsc[1::2], acsc[::2]))
        return keymap, acscmap


def set_cloexec(fd: int) -> None:
    flags = fcntl.fcntl(fd, fcntl.F_GETFD, 0)
    flags |= fcntl.FD_CLOEXEC
    fcntl.fcntl(fd, fcntl.F_SETFD, flags)

class ExecutionError(Exception):
    pass


class ForkPty:
    def __init__(self, argv: list[str], environ: dict[str, str] = {}):
        self.argv = argv
        self.environ = environ
        self.pid = -1
        self.masterfd = -1
        self.startpipew = -1
        self.errpiper = -1
        self.exitcode = 255

    def __enter__(self) -> int:
        assert self.pid == -1
        assert self.masterfd == -1
        assert self.startpipew == -1
        assert self.errpiper == -1
        startpiper, self.startpipew = os.pipe()
        self.errpiper, errpipew = os.pipe()
        set_cloexec(errpipew)
        self.pid, self.masterfd = pty.fork()
        if self.pid == 0: # child
            os.close(self.startpipew)
            os.close(self.errpiper)
            os.environ.update(self.environ)
            # wait for the parent
            os.read(startpiper, 1)
            os.close(startpiper)
            try:
                os.execvp(self.argv[0], self.argv)
            except OSError as err:
                os.write(
                    errpipew,
                    f"exec failed: {err}".encode(
                        locale.getpreferredencoding()
                    ),
                )
            sys.exit(255)

        os.close(startpiper)
        os.close(errpipew)
        return self.masterfd

    def start(self) -> None:
        """Allow the process to start executing.
        @raises ExecutionError: when execvp in the child fails
        """
        assert self.startpipew >= 0
        assert self.errpiper >= 0
        # signal that execvp can proceed
        os.write(self.startpipew, b"\0")
        os.close(self.startpipew)
        self.startpipew = -1
        # check for execvp errors
        data = os.read(self.errpiper, 1024)
        os.close(self.errpiper)
        self.errpiper = -1
        if data:
            raise ExecutionError(data)

    def __exit__(self, *_: typing.Any) -> None:
        assert self.pid > 0
        assert self.masterfd >= 0
        assert self.startpipew == -1
        assert self.errpiper == -1
        os.close(self.masterfd)
        status = os.waitpid(self.pid, 0)[1]
        if status & 0xff == 0: # not killed by a signal
            self.exitcode = status >> 8


def main() -> None:
    parser = optparse.OptionParser()
    parser.disable_interspersed_args()
    parser.add_option("-c", "--columns", dest="columns", metavar="N",
                      type="int", default=2, help="number of columns")
    parser.add_option("-r", "--reverse", action="store_true",
                      dest="reverse", default=False,
                      help="order last column to the left")
    options, args = parser.parse_args()
    keymapping, acsc = compute_keymap(symbolic_keymapping)

    def screenfactory(realscreen: curses.window) -> Columns:
        return Columns(realscreen, options.columns, reverse=options.reverse)

    if locale.getpreferredencoding() != 'UTF-8':
        t = Terminal(acsc, screenfactory)
    else:
        t = UTF8Terminal(acsc, screenfactory)

    process = ForkPty(args or [os.environ["SHELL"]], dict(TERM="ansi"))
    try:
        with process as masterfd:
            with t:
                assert t.realscreen is not None
                t.resizepty(masterfd)
                process.start()
                while True:
                    timeout = 0 if t.refresh_needed() else None
                    try:
                        res = select.select([0, masterfd], [], [], timeout)[0]
                    except select.error as err:
                        if err.args[0] == errno.EINTR:
                            t.resized()
                            t.resizepty(masterfd)
                            continue
                        raise
                    if 0 in res:
                        while True:
                            key = t.realscreen.getch()
                            if key == -1:
                                break
                            if key == 0xb3:
                                t.switchmode()
                                t.resizepty(masterfd)
                            elif key in keymapping:
                                os.write(masterfd, keymapping[key])
                            elif key <= 0xff:
                                os.write(masterfd, struct.pack("B", key))
                            else:
                                if "TCVT_DEVEL" in os.environ:
                                    raise ValueError("getch returned %d" % key)
                        t.refresh(0.1)
                    elif masterfd in res:
                        try:
                            data = os.read(masterfd, 1024)
                        except OSError:
                            break
                        if not data:
                            break
                        for char in data:
                            try:
                                response = t.feed(char)
                            except ValueError:
                                if "TCVT_DEVEL" in os.environ:
                                    raise
                                t.feed_reset()
                            else:
                                if response:
                                    os.write(masterfd, response)
                        t.refresh(0.1)
                    else:
                        t.refresh()
    except ExecutionError as err:
        os.write(2, err.args[0] + b"\n")
        sys.exit(255)
    else:
        sys.exit(process.exitcode)

if __name__ == '__main__':
    main()
