tuikit/driver_curses.py
author Radek Brich <radek.brich@devl.cz>
Tue, 11 Oct 2011 10:09:58 +0200
changeset 26 37745c5abc49
parent 25 f69a1f0382ce
child 27 139d1241b4c5
permissions -rw-r--r--
DriverPygame: add mouse events and key press autorepeat.

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

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

from tuikit.driver import Driver
from tuikit.common import MouseEvent


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_names = {
        '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,
    }

    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.colorprefix = [] # stack of color prefixes
        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_names[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()
            trans = {
                'blink'     : curses.A_BLINK,
                'bold'      : curses.A_BOLD,
                'dim'       : curses.A_DIM,
                'standout'  : curses.A_STANDOUT,
                'underline' : curses.A_UNDERLINE,
            }
            res = res | trans[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 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)

    def pushcolorprefix(self, name):
        self.colorprefix.append(name)

    def popcolorprefix(self):
        self.colorprefix.pop()


    ## 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 []

        ev = MouseEvent(x, y)

        out = []

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

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

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

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

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

        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

        ev = MouseEvent(x, y)
        out = []

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

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

        elif t in (0x60, 0x61): # wheel up, down
            ev.button = 4 + t - 0x60
            out += [('mousewheel', ev)]

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

        return out