tuikit/driver_curses.py
author Radek Brich <radek.brich@devl.cz>
Sat, 29 Dec 2012 12:16:06 +0100
changeset 41 37b7dfc3eae6
parent 34 e3beacd5e536
child 46 2b43a7f38c34
permissions -rw-r--r--
Update Emitter: All event handlers now have exactly one argument: object inherited from Event class, which carries any data.

# -*- coding: utf-8 -*-

import curses.wrapper
import curses.ascii
import locale
import logging

from tuikit.driver import Driver


class DriverCurses(Driver):
    xterm_codes = (
        (0x09,                      'tab'           ),
        (0x0a,                      'enter'         ),
        (0x7f,                      'backspace'     ),
        (0x1b,                      'escape'        ),
        (0x1b,0x4f,0x50,            'f1'            ),
        (0x1b,0x4f,0x51,            'f2'            ),
        (0x1b,0x4f,0x52,            'f3'            ),
        (0x1b,0x4f,0x53,            'f4'            ),
        (0x1b,0x5b,0x31,0x35,0x7e,  'f5'            ),
        (0x1b,0x5b,0x31,0x37,0x7e,  'f6'            ),
        (0x1b,0x5b,0x31,0x38,0x7e,  'f7'            ),
        (0x1b,0x5b,0x31,0x39,0x7e,  'f8'            ),
        (0x1b,0x5b,0x31,0x7e,       'home'          ),  # linux
        (0x1b,0x5b,0x32,0x30,0x7e,  'f9'            ),
        (0x1b,0x5b,0x32,0x31,0x7e,  'f10'           ),
        (0x1b,0x5b,0x32,0x33,0x7e,  'f11'           ),
        (0x1b,0x5b,0x32,0x34,0x7e,  'f12'           ),
        (0x1b,0x5b,0x32,0x7e,       'insert'        ),
        (0x1b,0x5b,0x33,0x7e,       'delete'        ),
        (0x1b,0x5b,0x34,0x7e,       'end'           ),  # linux
        (0x1b,0x5b,0x35,0x7e,       'pageup'        ),
        (0x1b,0x5b,0x36,0x7e,       'pagedown'      ),
        (0x1b,0x5b,0x41,            'up'            ),
        (0x1b,0x5b,0x42,            'down'          ),
        (0x1b,0x5b,0x43,            'right'         ),
        (0x1b,0x5b,0x44,            'left'          ),
        (0x1b,0x5b,0x46,            'end'           ),
        (0x1b,0x5b,0x48,            'home'          ),
        (0x1b,0x5b,0x4d,            'mouse'         ),
        (0x1b,0x5b,0x5b,0x41,       'f1'            ),  # linux
        (0x1b,0x5b,0x5b,0x42,       'f2'            ),  # linux
        (0x1b,0x5b,0x5b,0x43,       'f3'            ),  # linux
        (0x1b,0x5b,0x5b,0x44,       'f4'            ),  # linux
        (0x1b,0x5b,0x5b,0x45,       'f5'            ),  # linux
    )

    color_map = {
        'black'   : curses.COLOR_BLACK,
        'blue'    : curses.COLOR_BLUE,
        'cyan'    : curses.COLOR_CYAN,
        'green'   : curses.COLOR_GREEN,
        'magenta' : curses.COLOR_MAGENTA,
        'red'     : curses.COLOR_RED,
        'white'   : curses.COLOR_WHITE,
        'yellow'  : curses.COLOR_YELLOW,
    }

    attr_map = {
        'blink'     : curses.A_BLINK,
        'bold'      : curses.A_BOLD,
        'dim'       : curses.A_DIM,
        'standout'  : curses.A_STANDOUT,
        'underline' : curses.A_UNDERLINE,
    }

    def __init__(self):
        '''Set driver attributes to default values.'''
        Driver.__init__(self)
        self.log = logging.getLogger('tuikit')
        self.screen = None
        self.cursor = None
        self.colors = {}     # maps names to curses attributes
        self.colorpairs = {} # maps tuple (fg,bg) to curses color_pair
        self.colorstack = [] # pushcolor/popcolor puts or gets attributes from this
        self.inputqueue = []
        self.mbtnstack = []

    def init_curses(self):
        '''Initialize curses'''
        self.size.h, self.size.w = self.screen.getmaxyx()
        self.screen.immedok(0)
        self.screen.keypad(0)
        curses.curs_set(False)  # hide cursor
        curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION)
        curses.mouseinterval(0)  # do not wait to detect clicks, we use only press/release

    def start(self, mainfunc):
        def main(screen):
            self.screen = screen
            self.init_curses()
            mainfunc()
        curses.wrapper(main)


    ## colors, attributes ##

    def _parsecolor(self, name):
        name = name.lower().strip()
        return self.color_map[name]

    def _getcolorpair(self, fg, bg):
        pair = (fg, bg)
        if pair in self.colorpairs:
            return self.colorpairs[pair]
        num = len(self.colorpairs) + 1
        curses.init_pair(num, fg, bg)
        self.colorpairs[pair] = num
        return num

    def _parseattrs(self, attrs):
        res = 0
        for a in attrs:
            a = a.lower().strip()
            res = res | self.attr_map[a]
        return res

    def setcolor(self, name, desc):
        parts = desc.split(',')
        fg, bg = parts[0].split(' on ')
        attrs = parts[1:]
        fg = self._parsecolor(fg)
        bg = self._parsecolor(bg)
        col = self._getcolorpair(fg, bg)
        attr = self._parseattrs(attrs)
        self.colors[name] = curses.color_pair(col) | attr

    def pushcolor(self, name):
        # add prefix if such color is available
        if len(self.colorprefix):
            prefixname = self.colorprefix[-1] + name
            if prefixname in self.colors:
                name = prefixname
        attr = self.colors[name]
        self.screen.attrset(attr)
        self.colorstack.append(attr)

    def popcolor(self):
        self.colorstack.pop()
        if len(self.colorstack):
            attr = self.colorstack[-1]
        else:
            attr = 0
        self.screen.attrset(attr)


    ## drawing ##

    def putch(self, x, y, c):
        if not self.clipstack.test(x, y):
            return
        try:
            if isinstance(c, str) and len(c) == 1:
                self.screen.addstr(y, x, c)
            else:
                self.screen.addch(y, x, c)
        except curses.error:
            pass

    def erase(self):
        self.screen.erase()

    def commit(self):
        if self.cursor:
            self.screen.move(*self.cursor)
            curses.curs_set(True)
        else:
            curses.curs_set(False)
        self.screen.refresh()


    ## cursor ##

    def showcursor(self, x, y):
        if not self.clipstack.test(x, y):
            return
        self.cursor = (y, x)

    def hidecursor(self):
        curses.curs_set(False)
        self.cursor = None


    ## input ##

    def inputqueue_fill(self, timeout=None):
        if timeout is None:
            # wait indefinitely
            c = self.screen.getch()
            self.inputqueue.insert(0, c)

        elif timeout > 0:
            # wait
            curses.halfdelay(timeout)
            c = self.screen.getch()
            curses.cbreak()
            if c == -1:
                return
            self.inputqueue.insert(0, c)

        # timeout = 0 -> no wait

        self.screen.nodelay(1)

        while True:
            c = self.screen.getch()
            if c == -1:
                break
            self.inputqueue.insert(0, c)

        self.screen.nodelay(0)


    def inputqueue_top(self, num=0):
        return self.inputqueue[-1-num]


    def inputqueue_get(self):
        c = None
        try:
            c = self.inputqueue.pop()
        except IndexError:
            pass
        return c


    def inputqueue_get_wait(self):
        c = None
        while c is None:
            try:
                c = self.inputqueue.pop()
            except IndexError:
                curses.napms(25)
                self.inputqueue_fill(0)
        return c


    def inputqueue_unget(self, c):
        self.inputqueue.append(c)


    def getevents(self, timeout=None):
        # empty queue -> fill
        if len(self.inputqueue) == 0:
            self.inputqueue_fill(timeout)

        res = []
        while len(self.inputqueue):
            c = self.inputqueue_get()

            if c == curses.KEY_MOUSE:
                res += self.process_mouse()

            elif c == curses.KEY_RESIZE:
                self.size.h, self.size.w = self.screen.getmaxyx()
                res.append(('resize',))

            elif curses.ascii.isctrl(c):
                self.inputqueue_unget(c)
                res += self.process_control_chars()

            elif c >= 192 and c <= 255:
                self.inputqueue_unget(c)
                res += self.process_utf8_chars()

            elif curses.ascii.isprint(c):
                res += [('keypress', None, str(chr(c)))]

            else:
                #self.top.keypress(None, unicode(chr(c)))
                self.inputqueue_unget(c)
                res += self.process_control_chars()

        return res


    def process_mouse(self):
        try:
            _id, x, y, _z, bstate = curses.getmouse()
        except curses.error:
            return []

        out = []

        if bstate & curses.REPORT_MOUSE_POSITION:
            out += [('mousemove', x, y)]

        if bstate & curses.BUTTON1_PRESSED:
            out += [('mousedown', x, y, 1)]

        if bstate & curses.BUTTON3_PRESSED:
            out += [('mousedown', x, y, 3)]

        if bstate & curses.BUTTON1_RELEASED:
            out += [('mouseup', x, y, 1)]

        if bstate & curses.BUTTON3_RELEASED:
            out += [('mouseup', x, y, 3)]

        return out


    def process_utf8_chars(self):
        #FIXME read exact number of chars as defined by utf-8
        utf = []
        while len(utf) <= 6:
            c = self.inputqueue_get_wait()
            utf.append(c)
            try:
                uni = str(bytes(utf), 'utf-8')
                return [('keypress', None, uni)]
            except UnicodeDecodeError:
                continue
        raise Exception('Invalid UTF-8 sequence: %r' % utf)


    def process_control_chars(self):
        codes = self.xterm_codes
        matchingcodes = []
        match = None
        consumed = []

        # consume next char, filter out matching codes
        c = self.inputqueue_get_wait()
        consumed.append(c)

        while True:
            #self.log.debug('c=%s len=%s', c, len(codes))
            for code in codes:
                if c == code[len(consumed)-1]:
                    if len(code) - 1 == len(consumed):
                        match = code
                    else:
                        matchingcodes += [code]

            #self.log.debug('matching=%s', len(matchingcodes))

            # match found, or no matching code found -> stop
            if len(matchingcodes) == 0:
                break

            # match found and some sequencies still match -> continue
            if len(matchingcodes) > 0:
                if len(self.inputqueue) == 0:
                    self.inputqueue_fill(1)

            c = self.inputqueue_get()
            if c:
                consumed.append(c)
                codes = matchingcodes
                matchingcodes = []
            else:
                break

        keyname = None
        if match:
            # compare match to consumed, return unused chars
            l = len(match) - 1
            while len(consumed) > l:
                self.inputqueue_unget(consumed[-1])
                del consumed[-1]
            keyname = match[-1]

        if match is None:
            self.log.debug('Unknown control sequence: %s',
                ','.join(['0x%x'%x for x in consumed]))
            return [('keypress', 'Unknown', None)]

        if keyname == 'mouse':
            return self.process_xterm_mouse()

        return [('keypress', keyname, None)]


    def process_xterm_mouse(self):
        t = self.inputqueue_get_wait()
        x = self.inputqueue_get_wait() - 0x21
        y = self.inputqueue_get_wait() - 0x21

        out = []

        if t in (0x20, 0x21, 0x22): # button press
            btn = t - 0x1f
            if not btn in self.mbtnstack:
                self.mbtnstack.append(btn)
                out += [('mousedown', x, y, btn)]
            else:
                out += [('mousemove', x, y, btn)]

        elif t == 0x23: # button release
            btn = self.mbtnstack.pop()
            out += [('mouseup', x, y, btn)]

        elif t in (0x60, 0x61): # wheel up, down
            btn = 4 + t - 0x60
            out += [('mousewheel', x, y, btn)]

        else:
            raise Exception('Unknown mouse event: %x' % t)

        return out


driverclass = DriverCurses