tuikit/driver/cursesw.py
author Radek Brich <radek.brich@devl.cz>
Sat, 21 Feb 2015 12:01:57 +0100
changeset 118 8c7970520632
parent 113 6796adfdc7eb
child 119 dd91747504dd
permissions -rw-r--r--
Add mouse events, event demo.

import curses.ascii
import math
import logging

from tuikit.driver.driver import Driver
from tuikit.core.events import ResizeEvent, KeypressEvent, MouseEvent
from tuikit.core.coords import Point


class CursesWDriver(Driver):

    key_names = {
        '\t':                   'tab',
        '\n':                   'enter',
        '\x1b':                 'escape',
    }

    key_map = {
        curses.KEY_UP:          'up',
        curses.KEY_DOWN:        'down',
        curses.KEY_LEFT:        'left',
        curses.KEY_RIGHT:       'right',
        curses.KEY_IC:          'insert',
        curses.KEY_DC:          'delete',
        curses.KEY_HOME:        'home',
        curses.KEY_END:         'end',
        curses.KEY_PPAGE:       'pageup',
        curses.KEY_NPAGE:       'pagedown',
        curses.KEY_BACKSPACE:   'backspace',
        curses.KEY_BTAB:        'shift+tab',
    }
    for _i in range(1, 13):
        key_map[curses.KEY_F0 + _i] = 'f' + str(_i)

    color_map = {
        'default':      (-1,                    0),
        'black':        (curses.COLOR_BLACK,    0),
        'blue':         (curses.COLOR_BLUE,     0),
        'green':        (curses.COLOR_GREEN,    0),
        'cyan':         (curses.COLOR_CYAN,     0),
        'red':          (curses.COLOR_RED,      0),
        'magenta':      (curses.COLOR_MAGENTA,  0),
        'brown':        (curses.COLOR_YELLOW,   0),
        'lightgray':    (curses.COLOR_WHITE,    0),
        'gray':         (curses.COLOR_BLACK,    curses.A_BOLD),
        'lightblue':    (curses.COLOR_BLUE,     curses.A_BOLD),
        'lightgreen':   (curses.COLOR_GREEN,    curses.A_BOLD),
        'lightcyan':    (curses.COLOR_CYAN,     curses.A_BOLD),
        'lightred':     (curses.COLOR_RED,      curses.A_BOLD),
        'lightmagenta': (curses.COLOR_MAGENTA,  curses.A_BOLD),
        'yellow':       (curses.COLOR_YELLOW,   curses.A_BOLD),
        'white':        (curses.COLOR_WHITE,    curses.A_BOLD),
    }

    attr_map = {
        'bold':         curses.A_BOLD,
        'underline':    curses.A_UNDERLINE,
        'standout':     curses.A_STANDOUT,  # inverse bg/fg
        'blink':        curses.A_BLINK,
    }

    def __init__(self):
        Driver.__init__(self)
        self._log = logging.getLogger(__name__)
        self.stdscr = None
        self.cursor = None
        self.colors = {}     # maps names to curses attributes
        self.colorpairs = {} # maps tuple (fg,bg) to curses color_pair
        self._mouse_last_pos = None  # Point
        self._mouse_last_bstate = None

    ## initialization, finalization ##

    def init(self):
        """Initialize curses"""
        self.stdscr = curses.initscr()
        curses.start_color()
        curses.use_default_colors()
        curses.noecho()
        curses.cbreak()
        self.stdscr.keypad(1)
        self.stdscr.immedok(0)

        self.size.h, self.size.w = self.stdscr.getmaxyx()

        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 close(self):
        self.stdscr.keypad(0)
        curses.echo()
        curses.nocbreak()
        curses.endwin()

    ## drawing ##

    def clear(self):
        self.stdscr.erase()

    def putch(self, ch, x, y):
        try:
            if isinstance(ch, int):
                self.stdscr.addch(y, x, ch)
            elif isinstance(ch, str) and len(ch) == 1:
                self.stdscr.addstr(y, x, ch)
            else:
                raise TypeError('Integer or one-char string is required.')
        except curses.error:
            if x == self.size.w - 1 and y == self.size.h - 1:
                # Curses putch to lower-right corner gives error because
                # scrolling is disabled and cursor cannot move to next char.
                # Let's just ignore that for now.
                return
            self._log.exception('putch(%r, %s, %s) error:' % (ch, x, y))

    def draw(self, buffer, x=0, y=0):
        for bufy in range(buffer.size.h):
            for bufx in range(buffer.size.w):
                char, attr_desc = buffer.get(bufx, bufy)
                self.setattr(attr_desc)
                self.putch(char, x + bufx, y + bufy)

    def flush(self):
        if self.cursor:
            self.stdscr.move(self.cursor.y, self.cursor.x)
            curses.curs_set(True)
        else:
            curses.curs_set(False)
        self.stdscr.refresh()

    ## colors, attributes ##

    def setattr(self, attr_desc):
        """Set attribute to be used for subsequent draw operations."""
        attr = self.colors.get(attr_desc, None)
        if attr is None:
            # first encountered `attr_desc`, initialize
            fg, bg, attrs = self._parse_attr_desc(attr_desc)
            fgcol, fgattr = self.color_map[fg]
            bgcol, _bgattr = self.color_map[bg]
            colpair = self._getcolorpair(fgcol, bgcol)
            attr = curses.color_pair(colpair) | self._parseattrs(attrs) | fgattr
            self.colors[attr_desc] = attr
        self.stdscr.attrset(attr)

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

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

    ## input, events ##

    def getevents(self, timeout=None):
        """Process input, return list of events.

        timeout -- float, in seconds (None=infinite)

        Returns:
            List of Event objects.

        """
        # Set timeout
        if timeout is None:
            # wait indefinitely
            curses.cbreak()
        elif timeout > 0:
            # wait
            timeout_tenths = math.ceil(timeout * 10)
            curses.halfdelay(timeout_tenths)
        else:
            # timeout = 0 -> no wait
            self.stdscr.nodelay(1)

        # Get key or char
        c = self.stdscr.get_wch()

        res = []

        if c == -1:
            # Timeout
            return res
        elif c == curses.KEY_MOUSE:
            res += self._process_mouse()
        elif c == curses.KEY_RESIZE:
            self.size.h, self.size.w = self.stdscr.getmaxyx()
            res.append(ResizeEvent(self.size.w, self.size.h))
        elif isinstance(c, int):
            keyname, mods = self._split_keyname_mods(self.key_map[c])
            res.append(KeypressEvent(keyname, None, mods))
        else:
            keyname = self.key_names.get(c)
            res.append(KeypressEvent(keyname, c, set()))

        return res

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

        pos = Point(x, y)
        if bstate & curses.REPORT_MOUSE_POSITION:
            if self._mouse_last_pos != pos:
                if self._mouse_last_pos:
                    relpos = pos - self._mouse_last_pos
                    res.append(MouseEvent('mousemove', 0, pos, relpos))
                self._mouse_last_pos = pos

        # We are interested only in changes, not buttons already pressed before event
        if self._mouse_last_bstate is not None:
            old = self._mouse_last_bstate
            new = bstate
            bstate = ~old & new
            self._mouse_last_bstate = new
        else:
            self._mouse_last_bstate = bstate

        if bstate & curses.BUTTON1_PRESSED:
            res.append(MouseEvent('mousedown', 1, pos))
        if bstate & curses.BUTTON2_PRESSED:
            res.append(MouseEvent('mousedown', 2, pos))
        if bstate & curses.BUTTON3_PRESSED:
            res.append(MouseEvent('mousedown', 3, pos))
        if bstate & curses.BUTTON1_RELEASED:
            res.append(MouseEvent('mouseup', 1, pos))
        if bstate & curses.BUTTON2_RELEASED:
            res.append(MouseEvent('mouseup', 2, pos))
        if bstate & curses.BUTTON3_RELEASED:
            res.append(MouseEvent('mouseup', 3, pos))

        # Reset last pos when pressed/released
        if len(res) > 0 and res[-1].name in ('mousedown', 'mouseup'):
            self._mouse_last_pos = None

        return res

    def _split_keyname_mods(self, keyname):
        """Parse keynames in form "shift+tab", return (keyname, mod)."""
        mod_set = set()
        if '+' in keyname:
            parts = keyname.split('+')
            for mod in parts[:-1]:
                assert(mod in ('shift', 'alt', 'ctrl', 'meta'))
                mod_set.add(mod)
            keyname = parts[-1]

        return keyname, mod_set


driver_class = CursesWDriver