tuikit/driver_curses.py
changeset 23 4e72fd2a0e14
parent 21 8553a6bd2d82
child 24 b248ef500557
equal deleted inserted replaced
22:6ca8b2d221c3 23:4e72fd2a0e14
       
     1 # -*- coding: utf-8 -*-
       
     2 
       
     3 import curses
       
     4 import curses.ascii
       
     5 import curses.wrapper
       
     6 import locale
       
     7 import logging
       
     8 
       
     9 from tuikit.common import Size, Rect, ClipStack
       
    10 
       
    11 
       
    12 class MouseEvent:
       
    13     def __init__(self, x=0, y=0):
       
    14         self.x = x   # global coordinates
       
    15         self.y = y
       
    16         self.wx = x  # local widget coordinates
       
    17         self.wy = y
       
    18         self.px = 0  # parent coordinates
       
    19         self.py = 0
       
    20         self.button = 0
       
    21 
       
    22 
       
    23     def childevent(self, child):
       
    24         ev = MouseEvent(self.x, self.y)
       
    25         # original local coordinates are new parent coordinates
       
    26         ev.px = self.wx
       
    27         ev.py = self.wy
       
    28         # update local coordinates
       
    29         ev.wx = self.wx - child.x
       
    30         ev.wy = self.wy - child.y
       
    31 
       
    32         return ev
       
    33 
       
    34 
       
    35 class DriverCurses:
       
    36     xterm_codes = (
       
    37         (0x09,                      'tab'           ),
       
    38         (0x0a,                      'enter'         ),
       
    39         (0x7f,                      'backspace'     ),
       
    40         (0x1b,                      'escape'        ),
       
    41         (0x1b,0x4f,0x50,            'f1'            ),
       
    42         (0x1b,0x4f,0x51,            'f2'            ),
       
    43         (0x1b,0x4f,0x52,            'f3'            ),
       
    44         (0x1b,0x4f,0x53,            'f4'            ),
       
    45         (0x1b,0x5b,0x31,0x35,0x7e,  'f5'            ),
       
    46         (0x1b,0x5b,0x31,0x37,0x7e,  'f6'            ),
       
    47         (0x1b,0x5b,0x31,0x38,0x7e,  'f7'            ),
       
    48         (0x1b,0x5b,0x31,0x39,0x7e,  'f8'            ),
       
    49         (0x1b,0x5b,0x31,0x7e,       'home'          ),  # linux
       
    50         (0x1b,0x5b,0x32,0x30,0x7e,  'f9'            ),
       
    51         (0x1b,0x5b,0x32,0x31,0x7e,  'f10'           ),
       
    52         (0x1b,0x5b,0x32,0x33,0x7e,  'f11'           ),
       
    53         (0x1b,0x5b,0x32,0x34,0x7e,  'f12'           ),
       
    54         (0x1b,0x5b,0x32,0x7e,       'insert'        ),
       
    55         (0x1b,0x5b,0x33,0x7e,       'delete'        ),
       
    56         (0x1b,0x5b,0x34,0x7e,       'end'           ),  # linux
       
    57         (0x1b,0x5b,0x35,0x7e,       'pageup'        ),
       
    58         (0x1b,0x5b,0x36,0x7e,       'pagedown'      ),
       
    59         (0x1b,0x5b,0x41,            'up'            ),
       
    60         (0x1b,0x5b,0x42,            'down'          ),
       
    61         (0x1b,0x5b,0x43,            'right'         ),
       
    62         (0x1b,0x5b,0x44,            'left'          ),
       
    63         (0x1b,0x5b,0x46,            'end'           ),
       
    64         (0x1b,0x5b,0x48,            'home'          ),
       
    65         (0x1b,0x5b,0x4d,            'mouse'         ),
       
    66         (0x1b,0x5b,0x5b,0x41,       'f1'            ),  # linux
       
    67         (0x1b,0x5b,0x5b,0x42,       'f2'            ),  # linux
       
    68         (0x1b,0x5b,0x5b,0x43,       'f3'            ),  # linux
       
    69         (0x1b,0x5b,0x5b,0x44,       'f4'            ),  # linux
       
    70         (0x1b,0x5b,0x5b,0x45,       'f5'            ),  # linux
       
    71     )
       
    72 
       
    73     color_names = {
       
    74         'black'   : curses.COLOR_BLACK,
       
    75         'blue'    : curses.COLOR_BLUE,
       
    76         'cyan'    : curses.COLOR_CYAN,
       
    77         'green'   : curses.COLOR_GREEN,
       
    78         'magenta' : curses.COLOR_MAGENTA,
       
    79         'red'     : curses.COLOR_RED,
       
    80         'white'   : curses.COLOR_WHITE,
       
    81         'yellow'  : curses.COLOR_YELLOW,
       
    82     }
       
    83 
       
    84     def __init__(self):
       
    85         '''Set driver attributes to default values.'''
       
    86         self.screen = None
       
    87         self.size = Size()
       
    88         self.cursor = None
       
    89         self.clipstack = ClipStack()
       
    90         self.colors = {}     # maps names to curses attributes
       
    91         self.colorpairs = {} # maps tuple (fg,bg) to curses color_pair
       
    92         self.colorstack = [] # pushcolor/popcolor puts or gets attributes from this
       
    93         self.colorprefix = [] # stack of color prefixes
       
    94         self.inputqueue = []
       
    95         self.mbtnstack = []
       
    96 
       
    97         self.log = logging.getLogger('tuikit')
       
    98 
       
    99         # http://en.wikipedia.org/wiki/List_of_Unicode_characters#Geometric_shapes
       
   100         self.UP_ARROW = '▲' #curses.ACS_UARROW
       
   101         self.DOWN_ARROW = '▼' #curses.ACS_DARROW
       
   102 
       
   103         # http://en.wikipedia.org/wiki/Box-drawing_characters
       
   104         self.LIGHT_SHADE = '░' #curses.ACS_BOARD
       
   105         self.MEDIUM_SHADE = '▒'
       
   106         self.DARK_SHADE = '▓'
       
   107         self.BLOCK = '█'
       
   108 
       
   109         self.COLUMN = '▁▂▃▄▅▆▇█'
       
   110         self.CORNER_ROUND = '╭╮╰╯'
       
   111         self.CORNER = '┌┐└┘'
       
   112         self.LINE = '─━│┃┄┅┆┇┈┉┊┋'
       
   113 
       
   114         self.HLINE = '─' # curses.ACS_HLINE
       
   115         self.VLINE = '│' # curses.ACS_VLINE
       
   116         self.ULCORNER = '┌' # curses.ACS_ULCORNER
       
   117         self.URCORNER = '┐' # curses.ACS_URCORNER
       
   118         self.LLCORNER = '└' # curses.ACS_LLCORNER
       
   119         self.LRCORNER = '┘' # curses.ACS_LRCORNER
       
   120         self.LTEE = '├'
       
   121         self.RTEE = '┤'
       
   122 
       
   123     def init(self):
       
   124         '''Initialize curses'''
       
   125         self.size.h, self.size.w = self.screen.getmaxyx()
       
   126         self.screen.immedok(0)
       
   127         self.screen.keypad(0)
       
   128         curses.curs_set(False)  # hide cursor
       
   129         curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION)
       
   130         curses.mouseinterval(0)  # do not wait to detect clicks, we use only press/release
       
   131 
       
   132     def start(self, mainfunc):
       
   133         def main(screen):
       
   134             self.screen = screen
       
   135             self.init()
       
   136             mainfunc()
       
   137         curses.wrapper(main)
       
   138 
       
   139 
       
   140     ## colors, attributes ##
       
   141 
       
   142     def _parsecolor(self, name):
       
   143         name = name.lower().strip()
       
   144         return self.color_names[name]
       
   145 
       
   146 
       
   147     def _getcolorpair(self, fg, bg):
       
   148         pair = (fg, bg)
       
   149         if pair in self.colorpairs:
       
   150             return self.colorpairs[pair]
       
   151         num = len(self.colorpairs) + 1
       
   152         curses.init_pair(num, fg, bg)
       
   153         self.colorpairs[pair] = num
       
   154         return num
       
   155 
       
   156 
       
   157     def _parseattrs(self, attrs):
       
   158         res = 0
       
   159         for a in attrs:
       
   160             a = a.lower().strip()
       
   161             trans = {
       
   162                 'blink'     : curses.A_BLINK,
       
   163                 'bold'      : curses.A_BOLD,
       
   164                 'dim'       : curses.A_DIM,
       
   165                 'standout'  : curses.A_STANDOUT,
       
   166                 'underline' : curses.A_UNDERLINE,
       
   167             }
       
   168             res = res | trans[a]
       
   169         return res
       
   170 
       
   171 
       
   172     def setcolor(self, name, desc):
       
   173         parts = desc.split(',')
       
   174         fg, bg = parts[0].split(' on ')
       
   175         attrs = parts[1:]
       
   176         fg = self._parsecolor(fg)
       
   177         bg = self._parsecolor(bg)
       
   178         col = self._getcolorpair(fg, bg)
       
   179         attr = self._parseattrs(attrs)
       
   180         self.colors[name] = curses.color_pair(col) | attr
       
   181 
       
   182 
       
   183     def pushcolor(self, name):
       
   184         # add prefix if available
       
   185         if len(self.colorprefix):
       
   186             prefixname = self.colorprefix[-1] + name
       
   187             if prefixname in self.colors:
       
   188                 name = prefixname
       
   189         attr = self.colors[name]
       
   190         self.screen.attrset(attr)
       
   191         self.colorstack.append(attr)
       
   192 
       
   193 
       
   194     def popcolor(self):
       
   195         self.colorstack.pop()
       
   196         if len(self.colorstack):
       
   197             attr = self.colorstack[-1]
       
   198         else:
       
   199             attr = 0
       
   200         self.screen.attrset(attr)
       
   201 
       
   202 
       
   203     def pushcolorprefix(self, name):
       
   204         self.colorprefix.append(name)
       
   205 
       
   206 
       
   207     def popcolorprefix(self):
       
   208         self.colorprefix.pop()
       
   209 
       
   210 
       
   211     ## drawing ##
       
   212 
       
   213     def putch(self, x, y, c):
       
   214         if not self.clipstack.test(x, y):
       
   215             return
       
   216         try:
       
   217             if isinstance(c, str) and len(c) == 1:
       
   218                 self.screen.addstr(y, x, c)
       
   219             else:
       
   220                 self.screen.addch(y, x, c)
       
   221         except curses.error:
       
   222             pass
       
   223 
       
   224 
       
   225     def puts(self, x, y, s):
       
   226         for c in s:
       
   227             self.putch(x, y, c)
       
   228             x += 1
       
   229 
       
   230 
       
   231     def hline(self, x, y, w, c=' '):
       
   232         if isinstance(c, str):
       
   233             s = c*w
       
   234         else:
       
   235             s = [c]*w
       
   236         self.puts(x, y, s)
       
   237 
       
   238 
       
   239     def vline(self, x, y, h, c=' '):
       
   240         for i in range(h):
       
   241             self.putch(x, y+i, c)
       
   242 
       
   243 
       
   244     def frame(self, x, y, w, h):
       
   245         self.putch(x, y, self.ULCORNER)
       
   246         self.putch(x+w-1, y, self.URCORNER)
       
   247         self.putch(x, y+h-1, self.LLCORNER)
       
   248         self.putch(x+w-1, y+h-1, self.LRCORNER)
       
   249         self.hline(x+1, y, w-2, self.HLINE)
       
   250         self.hline(x+1, y+h-1, w-2, self.HLINE)
       
   251         self.vline(x, y+1, h-2, self.VLINE)
       
   252         self.vline(x+w-1, y+1, h-2, self.VLINE)
       
   253 
       
   254 
       
   255     def fill(self, x, y, w, h, c=' '):
       
   256         for i in range(h):
       
   257             self.hline(x, y + i, w, c)
       
   258 
       
   259 
       
   260     def erase(self):
       
   261         self.screen.erase()
       
   262 
       
   263 
       
   264     def commit(self):
       
   265         if self.cursor:
       
   266             self.screen.move(*self.cursor)
       
   267             curses.curs_set(True)
       
   268         else:
       
   269             curses.curs_set(False)
       
   270         self.screen.refresh()
       
   271 
       
   272 
       
   273     ## cursor ##
       
   274 
       
   275     def showcursor(self, x, y):
       
   276         if not self.clipstack.test(x, y):
       
   277             return
       
   278         self.cursor = (y, x)
       
   279 
       
   280 
       
   281     def hidecursor(self):
       
   282         curses.curs_set(False)
       
   283         self.cursor = None
       
   284 
       
   285 
       
   286     ## input ##
       
   287 
       
   288     def inputqueue_fill(self, timeout=None):
       
   289         if timeout is None:
       
   290             # wait indefinitely
       
   291             c = self.screen.getch()
       
   292             self.inputqueue.insert(0, c)
       
   293 
       
   294         elif timeout > 0:
       
   295             # wait
       
   296             curses.halfdelay(timeout)
       
   297             c = self.screen.getch()
       
   298             curses.cbreak()
       
   299             if c == -1:
       
   300                 return
       
   301             self.inputqueue.insert(0, c)
       
   302 
       
   303         # timeout = 0 -> no wait
       
   304 
       
   305         self.screen.nodelay(1)
       
   306 
       
   307         while True:
       
   308             c = self.screen.getch()
       
   309             if c == -1:
       
   310                 break
       
   311             self.inputqueue.insert(0, c)
       
   312 
       
   313         self.screen.nodelay(0)
       
   314 
       
   315 
       
   316     def inputqueue_top(self, num=0):
       
   317         return self.inputqueue[-1-num]
       
   318 
       
   319 
       
   320     def inputqueue_get(self):
       
   321         c = None
       
   322         try:
       
   323             c = self.inputqueue.pop()
       
   324         except IndexError:
       
   325             pass
       
   326         return c
       
   327 
       
   328 
       
   329     def inputqueue_get_wait(self):
       
   330         c = None
       
   331         while c is None:
       
   332             try:
       
   333                 c = self.inputqueue.pop()
       
   334             except IndexError:
       
   335                 curses.napms(25)
       
   336                 self.inputqueue_fill(0)
       
   337         return c
       
   338 
       
   339 
       
   340     def inputqueue_unget(self, c):
       
   341         self.inputqueue.append(c)
       
   342 
       
   343 
       
   344     def process_input(self, timeout=None):
       
   345         # empty queue -> fill
       
   346         if len(self.inputqueue) == 0:
       
   347             self.inputqueue_fill(timeout)
       
   348 
       
   349         res = []
       
   350         while len(self.inputqueue):
       
   351             c = self.inputqueue_get()
       
   352 
       
   353             if c == curses.KEY_MOUSE:
       
   354                 res += self.process_mouse()
       
   355             
       
   356             elif c == curses.KEY_RESIZE:
       
   357                 self.size.h, self.size.w = self.screen.getmaxyx()
       
   358                 res.append(('resize',))
       
   359 
       
   360             elif curses.ascii.isctrl(c):
       
   361                 self.inputqueue_unget(c)
       
   362                 res += self.process_control_chars()
       
   363 
       
   364             elif c >= 192 and c <= 255:
       
   365                 self.inputqueue_unget(c)
       
   366                 res += self.process_utf8_chars()
       
   367 
       
   368             elif curses.ascii.isprint(c):
       
   369                 res += [('keypress', None, str(chr(c)))]
       
   370 
       
   371             else:
       
   372                 #self.top.keypress(None, unicode(chr(c)))
       
   373                 self.inputqueue_unget(c)
       
   374                 res += self.process_control_chars()
       
   375 
       
   376         return res
       
   377 
       
   378 
       
   379     def process_mouse(self):
       
   380         try:
       
   381             id, x, y, z, bstate = curses.getmouse()
       
   382         except curses.error:
       
   383             return []
       
   384 
       
   385         ev = MouseEvent(x, y)
       
   386 
       
   387         out = []
       
   388 
       
   389         if bstate & curses.REPORT_MOUSE_POSITION:
       
   390             out += [('mousemove', ev)]
       
   391 
       
   392         if bstate & curses.BUTTON1_PRESSED:
       
   393             ev.button = 1
       
   394             out += [('mousedown', ev)]
       
   395 
       
   396         if bstate & curses.BUTTON3_PRESSED:
       
   397             ev.button = 3
       
   398             out += [('mousedown', ev)]
       
   399 
       
   400         if bstate & curses.BUTTON1_RELEASED:
       
   401             ev.button = 1
       
   402             out += [('mouseup', ev)]
       
   403 
       
   404         if bstate & curses.BUTTON3_RELEASED:
       
   405             ev.button = 3
       
   406             out += [('mouseup', ev)]
       
   407 
       
   408         return out
       
   409 
       
   410 
       
   411     def process_utf8_chars(self):
       
   412         #FIXME read exact number of chars as defined by utf-8
       
   413         utf = []
       
   414         while len(utf) <= 6:
       
   415             c = self.inputqueue_get_wait()
       
   416             utf.append(c)
       
   417             try:
       
   418                 uni = str(bytes(utf), 'utf-8')
       
   419                 return [('keypress', None, uni)]
       
   420             except UnicodeDecodeError:
       
   421                 continue
       
   422         raise Exception('Invalid UTF-8 sequence: %r' % utf)
       
   423 
       
   424 
       
   425     def process_control_chars(self):
       
   426         codes = self.xterm_codes
       
   427         matchingcodes = []
       
   428         match = None
       
   429         consumed = []
       
   430 
       
   431         # consume next char, filter out matching codes
       
   432         c = self.inputqueue_get_wait()
       
   433         consumed.append(c)
       
   434 
       
   435         while True:
       
   436             self.log.debug('c=%s len=%s', c, len(codes))
       
   437             for code in codes:
       
   438                 if c == code[len(consumed)-1]:
       
   439                     if len(code) - 1 == len(consumed):
       
   440                         match = code
       
   441                     else:
       
   442                         matchingcodes += [code]
       
   443 
       
   444             self.log.debug('matching=%s', len(matchingcodes))
       
   445 
       
   446             # match found, or no matching code found -> stop
       
   447             if len(matchingcodes) == 0:
       
   448                 break
       
   449 
       
   450             # match found and some sequencies still match -> continue
       
   451             if len(matchingcodes) > 0:
       
   452                 if len(self.inputqueue) == 0:
       
   453                     self.inputqueue_fill(1)
       
   454 
       
   455             c = self.inputqueue_get()
       
   456             if c:
       
   457                 consumed.append(c)
       
   458                 codes = matchingcodes
       
   459                 matchingcodes = []
       
   460             else:
       
   461                 break
       
   462 
       
   463         keyname = None
       
   464         if match:
       
   465             # compare match to consumed, return unused chars
       
   466             l = len(match) - 1
       
   467             while len(consumed) > l:
       
   468                 self.inputqueue_unget(consumed[-1])
       
   469                 del consumed[-1]
       
   470             keyname = match[-1]
       
   471 
       
   472         if match is None:
       
   473             self.log.debug('Unknown control sequence: %s',
       
   474                 ','.join(['0x%x'%x for x in consumed]))
       
   475             return [('keypress', 'Unknown', None)]
       
   476 
       
   477         if keyname == 'mouse':
       
   478             return self.process_xterm_mouse()
       
   479 
       
   480         return [('keypress', keyname, None)]
       
   481 
       
   482 
       
   483     def process_xterm_mouse(self):
       
   484         t = self.inputqueue_get_wait()
       
   485         x = self.inputqueue_get_wait() - 0x21
       
   486         y = self.inputqueue_get_wait() - 0x21
       
   487 
       
   488         ev = MouseEvent(x, y)
       
   489         out = []
       
   490 
       
   491         if t in (0x20, 0x21, 0x22): # button press
       
   492             btn = t - 0x1f
       
   493             ev.button = btn
       
   494             if not btn in self.mbtnstack:
       
   495                 self.mbtnstack.append(btn)
       
   496                 out += [('mousedown', ev)]
       
   497             else:
       
   498                 out += [('mousemove', ev)]
       
   499 
       
   500         elif t == 0x23: # button release
       
   501             ev.button = self.mbtnstack.pop()
       
   502             out += [('mouseup', ev)]
       
   503 
       
   504         elif t in (0x60, 0x61): # wheel up, down
       
   505             ev.button = 4 + t - 0x60
       
   506             out += [('mousewheel', ev)]
       
   507 
       
   508         else:
       
   509             raise Exception('Unknown mouse event: %x' % t)
       
   510 
       
   511         return out