tuikit/backend_curses.py
author Radek Brich <radek.brich@devl.cz>
Wed, 13 Apr 2011 01:33:27 +0200
changeset 7 d4a291b31cbb
parent 5 ae128c885d0f
child 9 7175ed629a76
permissions -rw-r--r--
New color management - named colors.

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

import curses
import curses.ascii
import locale
import logging

from .common import Rect


class MouseEvent:
    def __init__(self, x=0, y=0):
        self.x = x   # global coordinates
        self.y = y
        self.wx = x  # local widget coordinates
        self.wy = y
        self.px = 0  # parent coordinates
        self.py = 0
        self.button = 0


    def childevent(self, child):
        ev = MouseEvent(self.x, self.y)
        # original local coordinates are new parent coordinates
        ev.px = self.wx
        ev.py = self.wy
        # update local coordinates
        ev.wx = self.wx - child.x
        ev.wy = self.wy - child.y

        return ev


class BackendCurses:
    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, screen):
        self.screen = screen
        self.height, self.width = screen.getmaxyx()

        self.cursor = None
        self.clipstack = []

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

        self.log = logging.getLogger('tuikit')

        # initialize curses
        curses.curs_set(False)
        curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION)
        curses.mouseinterval(0)  # do not wait to detect clicks, we use only press/release

        screen.immedok(0)
        screen.keypad(0)

        # http://en.wikipedia.org/wiki/List_of_Unicode_characters#Geometric_shapes
        self.UP_ARROW = '▲' #curses.ACS_UARROW
        self.DOWN_ARROW = '▼' #curses.ACS_DARROW

        # http://en.wikipedia.org/wiki/Box-drawing_characters
        self.LIGHT_SHADE = '░' #curses.ACS_BOARD
        self.MEDIUM_SHADE = '▒'
        self.DARK_SHADE = '▓'
        self.BLOCK = '█'

        self.COLUMN = '▁▂▃▄▅▆▇█'
        self.CORNER_ROUND = '╭╮╰╯'
        self.CORNER = '┌┐└┘'
        self.LINE = '─━│┃┄┅┆┇┈┉┊┋'

        self.HLINE = '─' # curses.ACS_HLINE
        self.VLINE = '│' # curses.ACS_VLINE
        self.ULCORNER = '┌' # curses.ACS_ULCORNER
        self.URCORNER = '┐' # curses.ACS_URCORNER
        self.LLCORNER = '└' # curses.ACS_LLCORNER
        self.LRCORNER = '┘' # curses.ACS_LRCORNER
        self.LTEE = '├'
        self.RTEE = '┤'


    ## clip operations ##

    def pushclip(self, x, y, w, h):
        newclip = Rect(x, y, w, h)
        if len(self.clipstack):
            oldclip = self.clipstack[-1]
            newclip = self.intersect(oldclip, newclip)
        self.clipstack.append(newclip)


    def popclip(self):
        self.clipstack.pop()


    def testclip(self, x, y):
        # no clip rectangle on stack => passed
        if not len(self.clipstack):
            return True
        # test against top clip rect from stack
        clip = self.clipstack[-1]
        if x < clip.x or y < clip.y \
        or x >= clip.x + clip.w or y >= clip.y + clip.h:
            return False
        # passed
        return True


    def intersect(self, r1, r2):
        x1 = max(r1.x, r2.x)
        y1 = max(r1.y, r2.y)
        x2 = min(r1.x + r1.w, r2.x + r2.w)
        y2 = min(r1.y + r1.h, r2.y + r2.h)
        if x1 >= x2 or y1 >= y2:
            return Rect()
        return Rect(x1, y1, x2-x1, y2-y1)


    def union(self, r1, r2):
        x = min(r1.x, r2.x)
        y = min(r1.y, r2.y)
        w = max(r1.x + r1.w, r2.x + r2.w) - x
        h = max(r1.y + r1.h, r2.y + r2.h) - y
        return Rect(x, y, w, h)


    ## 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):
        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.testclip(x, y):
            return
        try:
            if type(c) is str and len(c) == 1:
                self.screen.addstr(y, x, c)
            else:
                self.screen.addch(y, x, c)
        except curses.error:
            pass


    def puts(self, x, y, s):
        for c in s:
            self.putch(x, y, c)
            x += 1


    def hline(self, x, y, w, c=' '):
        if type(c) is str:
            s = c*w
        else:
            s = [c]*w
        self.puts(x, y, s)


    def vline(self, x, y, h, c=' '):
        for i in range(h):
            self.putch(x, y+i, c)


    def frame(self, x, y, w, h):
        self.putch(x, y, self.ULCORNER)
        self.putch(x+w-1, y, self.URCORNER)
        self.putch(x, y+h-1, self.LLCORNER)
        self.putch(x+w-1, y+h-1, self.LRCORNER)
        self.hline(x+1, y, w-2, self.HLINE)
        self.hline(x+1, y+h-1, w-2, self.HLINE)
        self.vline(x, y+1, h-2, self.VLINE)
        self.vline(x+w-1, y+1, h-2, self.VLINE)


    def fill(self, x, y, w, h, c=' '):
        for i in range(h):
            self.hline(x, y + i, w, c)


    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.testclip(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 process_input(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 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 += chr(c)
            try:
                uni = str(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