timeyyy/pytknvim

View on GitHub
pytknvim/tk_ui.py

Summary

Maintainability
D
2 days
Test Coverage
'''
Implements a UI for neovim  using tkinter.

* The widget has lines updated/deleted so that any
  given time it only contains what is being displayed.

* The widget is filled with spaces
'''

import sys
import math
import time
from neovim import attach

# from tkquick.gui.tools import rate_limited

from pytknvim.ui_bridge import UIBridge
from pytknvim.screen import Screen
from pytknvim.util import _stringify_key, _stringify_color
from pytknvim.util import _split_color, _invert_color
from pytknvim.util import debug_echo
from pytknvim.util import attach_headless, attach_child
from pytknvim import tk_util

try:
    import Tkinter as tk
    import tkFont as tkfont
    import ttk
except ImportError:
    import tkinter as tk
    import tkinter.font as tkfont

import attr

RESIZE_DELAY = 0.04

def parse_tk_state(state):
    if state & 0x4:
        return 'Ctrl'
    elif state & 0x8:
        return 'Alt'
    elif state & 0x1:
        return 'Shift'


tk_modifiers = ('Alt_L', 'Alt_R',
                'Control_L', 'Control_R',
                'Shift_L', 'Shift_R',
                'Win_L', 'Win_R')


KEY_TABLE = {
    'slash': '/',
    'backslash': '\\',
    'asciicircumf': '^',
    'at': '@',
    'numbersign': '#',
    'dollar': '$',
    'percent': '%',
    'ampersand': '&',
    'asterisk': '*',
    'parenleft': '(',
    'parenright': ')',
    'underscore': '_',
    'plus': '+',
    'minus': '-',
    'bracketleft': '[',
    'bracketright': ']',
    'braceleft': '{',
    'braceright': '}',
    'quotedbl': '"',
    'apostrophe': "'",
    'less': "<",
    'greater': ">",
    'comma': ",",
    'period': ".",
    'BackSpace': 'BS',
    'Return': 'CR',
    'Escape': 'Esc',
    'Delete': 'Del',
    'Next': 'PageUp',
    'Prior': 'PageDown',
    'Enter': 'CR',
}


class MixTk():
    '''
    Tkinter actions we bind and use to communicate to neovim
    '''
    def tk_key_pressed(self,event, **k):
        keysym = event.keysym
        state = parse_tk_state(event.state)
        if event.char not in ('', ' ') \
                    and state in (None, 'Shift'):
            if event.keysym_num == ord(event.char):
                # Send through normal keys
                self._bridge.input(event.char)
                return
        if keysym in tk_modifiers:
            # We don't need to track the state of modifier bits
            return
        if keysym.startswith('KP_'):
            keysym = keysym[3:]

        # Translated so vim understands
        input_str = _stringify_key( KEY_TABLE.get(keysym, keysym), state)
        self._bridge.input(input_str)


    def _tk_quit(self, *args):
        self._bridge.exit()


    # @rate_limited(1/RESIZE_DELAY, mode='kill')
    def _tk_resize(self, event):
        '''Let Neovim know we are changing size'''
        cols = int(math.floor(event.width / self._colsize))
        rows = int(math.floor(event.height / self._rowsize))
        if self._screen.columns == cols:
            if self._screen.rows == rows:
                return
        self.current_cols = cols
        self.current_rows = rows
        self._bridge.resize(cols, rows)
        if self.debug_echo:
            print('resizing c, r, w, h',
                    cols,rows, event.width, event.height)


    def bind_resize(self):
        '''
        after calling,
        widget changes will now be passed along to neovim
        '''
        print('binding resize to', self, self.text)
        self._configure_id = self.text.bind('<Configure>', self._tk_resize)


    def unbind_resize(self):
        '''
        after calling,
        widget size changes will not be passed along to nvim
        '''
        print('unbinding resize from', self)
        self.text.unbind('<Configure>', self._configure_id)


    def _get_row(self, screen_row):
        '''change a screen row to a tkinter row,
        defaults to screen.row'''
        if screen_row is None:
            screen_row = self._screen.row
        return screen_row + 1


    def _get_col(self, screen_col):
        '''change a screen col to a tkinter row,
        defaults to screen.col'''
        if screen_col is None:
            screen_col = self._screen.col
        return screen_col


    def tk_delete_line(self, screen_col=None, screen_row=None,
                                       del_eol=False, count=1):
        '''
        To specifiy where to start the delete from
        screen_col (defualts to screen.row)
        screen_row (defaults to screen.col)

        To delete the eol char aswell
        del_eol (defaults to False)

        count is the number of lines to delete
        '''
        line = self._get_row(screen_row)
        col = self._get_col(screen_col)
        start = "%d.%d" % (line, col)
        if del_eol:
            end = "%d.0" % (line + count)
        else:
            end = "%d.end" % (line + count - 1)
        self.text.delete(start, end)
        gotten = self.text.get(start, end)
        if self.debug_echo == True:
            print('deleted  from ' + start + ' to end ' +end)
            print('deleted '+repr(gotten))


    def tk_pad_line(self, screen_col=None, add_eol=False,
                                    screen_row=None, count=1):
        '''
        add required blank spaces at the end of the line
        can apply action to multiple rows py passing a count
        in
        '''
        line = self._get_row(screen_row)
        col = self._get_col(screen_col)
        for n in range(0, count):
            start = "%d.%d" % (line + n, col)
            spaces = " " * (self.current_cols - col)
            if add_eol:
                spaces += '\n'
            if self.debug_echo:
                pass
                # print('padding from ', start, ' with %d: '
                                                # % len(spaces))
                # print(repr(spaces))
            self.text.insert(start, spaces)


    def _start_blinking(self):
        # cursor is drawn seperatley in the window
        row, col = self._screen.row, self._screen.col
        text, attrs = self._screen.get_cursor()
        pos = "%d.%d" % (row +1, col)

        if not attrs:
            attrs = self._get_tk_attrs(None)
        fg = attrs[1].get('foreground')
        bg = attrs[1].get('background')
        try:
            self.text.stop_blink()
        except Exception:
            pass
        self.text.blink_cursor(pos, fg, bg)


class NvimHandler(MixTk):
    '''These methods get called by neovim'''

    def __init__(self, text, toplevel, address=-1, debug_echo=False):
        self.text = text
        self.toplevel = toplevel
        self.debug_echo = debug_echo

        self._insert_cursor = False
        self._screen = None
        self._foreground = -1
        self._background = -1
        self._pending = [0,0,0]
        self._attrs = {}
        self._reset_attrs_cache()
        self._colsize = None
        self._rowsize = None

        # Have we connected to an nvim instance?
        self.connected = False
        # Connecition Info for neovim
        self.address = address
        cols = 80
        rows = 24
        self.current_cols = cols
        self.current_rows = rows

        self._screen = Screen(cols, rows)
        self._bridge = UIBridge()

    @debug_echo
    def connect(self, *nvim_args, address=None, headless=False, exec_name='nvim'):
        # Value has been set, otherwise default to this functions default value
        if self.address != -1 and not address:
            address = self.address

        if headless:
            nvim = attach_headless(nvim_args, address)
        elif address:
            nvim = attach('socket', path=address, argv=nvim_args)
        else:
            nvim = attach_child(nvim_args=nvim_args, exec_name=exec_name)

        self._bridge.connect(nvim, self.text)
        self._screen = Screen(self.current_cols, self.current_rows)
        self._bridge.attach(self.current_cols, self.current_rows, rgb=True)
        # if len(sys.argv) > 1:
            # nvim.command('edit ' + sys.argv[1])
        self.connected = True
        self.text.nvim = nvim
        return nvim

    @debug_echo
    def _nvim_resize(self, cols, rows):
        '''Let neovim update tkinter when neovim changes size'''
        # TODO
        # Make sure it works when user changes font,
        # only can support mono font i think..
        self._screen = Screen(cols, rows)

    @debug_echo
    def _nvim_clear(self):
        '''
        wipe everyything, even the ~ and status bar
        '''
        self._screen.clear()

        self.tk_delete_line(del_eol=True,
                            screen_row=0,
                            screen_col=0,
                            count=self.current_rows)
        # Add spaces everywhere
        self.tk_pad_line(screen_row=0,
                         screen_col=0,
                         count=self.current_rows,
                         add_eol=True,)


    @debug_echo
    def _nvim_eol_clear(self):
        '''
        delete from index to end of line,
        fill with whitespace
        leave eol intact
        '''
        self._screen.eol_clear()
        self.tk_delete_line(del_eol=False)
        self.tk_pad_line(screen_col=self._screen.col,
                         add_eol=False)


    @debug_echo
    def _nvim_cursor_goto(self, row, col):
        '''Move gui cursor to position'''
        self._screen.cursor_goto(row, col)
        self.text.see("1.0")


    @debug_echo
    def _nvim_busy_start(self):
        self._busy = True


    @debug_echo
    def _nvim_busy_stop(self):
        self._busy = False


    @debug_echo
    def _nvim_mouse_on(self):
        self.mouse_enabled = True


    @debug_echo
    def _nvim_mouse_off(self):
        self.mouse_enabled = False


    @debug_echo
    def _nvim_mode_change(self, mode):
        self._insert_cursor = mode == 'insert'


    @debug_echo
    def _nvim_set_scroll_region(self, top, bot, left, right):
        self._screen.set_scroll_region(top, bot, left, right)


    @debug_echo
    def _nvim_scroll(self, count):
        self._flush()
        self._screen.scroll(count)
        abs_count = abs(count)
        # The minus 1 is because we want our tk_* functions
        # to operate on the row passed in
        delta = abs_count - 1
        # Down
        if count > 0:
            delete_row = self._screen.top
            pad_row = self._screen.bot - delta
        # Up
        else:
            delete_row = self._screen.bot - delta
            pad_row = self._screen.top

        self.tk_delete_line(screen_row=delete_row,
                            screen_col=0,
                            del_eol=True,
                            count=abs_count)
        self.tk_pad_line(screen_row=pad_row,
                         screen_col=0,
                         add_eol=True,
                         count=abs_count)
        # self.text.yview_scroll(count, 'units')


    # @debug_echo
    def _nvim_highlight_set(self, attrs):
        self._attrs = self._get_tk_attrs(attrs)


    # @debug_echo
    def _reset_attrs_cache(self):
        self._tk_text_cache = {}
        self._tk_attrs_cache = {}


    @debug_echo
    def _get_tk_attrs(self, attrs):
        key = tuple(sorted((k, v,) for k, v in (attrs or {}).items()))
        rv = self._tk_attrs_cache.get(key, None)
        if rv is None:
            fg = self._foreground if self._foreground != -1\
                                                else 0
            bg = self._background if self._background != -1\
                                                else 0xffffff
            n = {'foreground': _split_color(fg),
                'background': _split_color(bg),}
            if attrs:
                # make sure that fg and bg are assigned first
                for k in ['foreground', 'background']:
                    if k in attrs:
                        n[k] = _split_color(attrs[k])
                for k, v in attrs.items():
                    if k == 'reverse':
                        n['foreground'], n['background'] = \
                            n['background'], n['foreground']
                    elif k == 'italic':
                        n['slant'] = 'italic'
                    elif k == 'bold':
                        n['weight'] = 'bold'
                        # TODO
                        # if self._bold_spacing:
                            # n['letter_spacing'] \
                                    # = str(self._bold_spacing)
                    elif k == 'underline':
                        n['underline'] = '1'
            c = dict(n)
            c['foreground'] = _invert_color(*_split_color(fg))
            c['background'] = _invert_color(*_split_color(bg))
            c['foreground'] = _stringify_color(*c['foreground'])
            c['background'] = _stringify_color(*c['background'])
            n['foreground'] = _stringify_color(*n['foreground'])
            n['background'] = _stringify_color(*n['background'])
            # n = normal, c = cursor
            rv = (n, c)
            self._tk_attrs_cache[key] = (n, c)
        return rv


    # @debug_echo
    def _nvim_put(self, text):
        '''
        put a charachter into position, we only write the lines
        when a new row is being edited
        '''
        if self._screen.row != self._pending[0]:
            # write to screen if vim puts stuff on  a new line
            self._flush()

        self._screen.put(text, self._attrs)
        self._pending[1] = min(self._screen.col - 1,
                               self._pending[1])
        self._pending[2] = max(self._screen.col,
                               self._pending[2])


    # @debug_echo
    def _nvim_bell(self):
        pass


    # @debug_echo
    def _nvim_visual_bell(self):
        pass


    # @debug_echo
    def _nvim_update_fg(self, fg):
        self._foreground = fg
        self._reset_attrs_cache()
        foreground = self._get_tk_attrs(None)[0]['foreground']
        self.text.config(foreground=foreground)


    # @debug_echo
    def _nvim_update_bg(self, bg):
        self._background = bg
        self._reset_attrs_cache()
        background = self._get_tk_attrs(None)[0]['background']
        self.text.config(background=background)


    # @debug_echo
    def _nvim_update_suspend(self, arg):
        self.root.iconify()


    # @debug_echo
    def _nvim_set_title(self, title):
        self.root.title(title)


    # @debug_echo
    def _nvim_set_icon(self, icon):
        self._icon = tk.PhotoImage(file=icon)
        self.root.tk.call('wm', 'iconphoto',
                          self.root._w, self._icon)


    # @debug_echo
    def _flush(self):
        row, startcol, endcol = self._pending
        self._pending[0] = self._screen.row
        self._pending[1] = self._screen.col
        self._pending[2] = self._screen.col
        if startcol == endcol:
            #print('startcol is endcol return, row %s col %s'% (self._screen.row, self._screen.col))
            return
        ccol = startcol
        buf = []
        bold = False
        for _, col, text, attrs in self._screen.iter(row,
                                    row, startcol, endcol - 1):
            newbold = attrs and 'bold' in attrs[0]
            if newbold != bold or not text:
                if buf:
                    self._draw(row, ccol, buf)
                bold = newbold
                buf = [(text, attrs,)]
                ccol = col
            else:
                buf.append((text, attrs,))
        if buf:
            self._draw(row, ccol, buf)
        else:
            pass
            # print('flush with no draw')


    @debug_echo
    def _draw(self, row, col, data):
        '''
        updates a line :)
        '''
        for text, attrs in data:
            try:
                start = end
            except UnboundLocalError:
                start = "{}.{}".format(row + 1, col)
            end = start+'+{0}c'.format(len(text))

            if not attrs:
                attrs = self._get_tk_attrs(None)
            attrs = attrs[0]

            # if self.debug_echo:
                # print('replacing ', repr(self.text.get(start, end)))
                # print('with ', repr(text), ' at ', start, ' ',end)
            self.text.replace(start, end, text)

            if attrs:
                self.text.apply_attribute(attrs, start, end)
            start


    @debug_echo
    def _nvim_exit(self, arg):
        print('in exit')
        import pdb;pdb.set_trace()
        # self.root.destroy()

    @debug_echo
    def _nvim_update_sp(self, *args):
        pass


class NvimTk(tk_util.Text):
    '''namespace for neovim related methods,
    requests are generally prefixed with _tk_,
    responses are prefixed with _nvim_
    '''
    # we get keys, mouse movements inside tkinter, using binds,
    # These binds are handed off to neovim using _input

    # Neovim interpruts the actions and calls certain
    # functions which are defined and implemented in tk

    # The api from neovim does stuff line by line,
    # so each callback from neovim produces a series
    # of miniscule actions which in the end updates a line

    # So we can shutdown the neovim connections
    instances = []

    def __init__(self, parent, *_, address=False, toplevel=False, **kwargs):
        '''
        :parent: normal tkinter parent or master of the widget
        :toplevel: , if true will resize based off the toplevel etc
        :address: neovim connection info
            named pipe /tmp/nvim/1231
            tcp/ip socket 127.0.0.1:4444
            'child'
            'headless'
        :kwargs: config options for text widget
        '''
        tk_util.Text.__init__(self, parent, **kwargs)
        self.nvim_handler = NvimHandler(text=self,
                                        toplevel=toplevel,
                                        address=address,
                                        debug_echo=False)

        # TODO weak ref?
        NvimTk.instances.append(self)

    def _nvimtk_config(self, *args):
        '''required config'''
        # Hide tkinter cursor
        self.config(insertontime=0)

        # Remove Default Bindings and what happens on insert etc
        bindtags = list(self.bindtags())
        bindtags.remove("Text")
        self.bindtags(tuple(bindtags))

        self.bind('<Key>', self.nvim_handler.tk_key_pressed)

        self.bind('<Button-1>', lambda e: self.focus_set())

        # The negative number makes it pixels instead of point sizes
        size = self.make_font_size(13)
        self._fnormal = tkfont.Font(family='Monospace', size=size)
        self._fbold = tkfont.Font(family='Monospace', weight='bold', size=size)
        self._fitalic = tkfont.Font(family='Monospace', slant='italic', size=size)
        self._fbolditalic = tkfont.Font(family='Monospace', weight='bold',
                                 slant='italic', size=size)
        self.config(font=self._fnormal, wrap=tk.NONE)

        self.nvim_handler._colsize = self._fnormal.measure('M')
        self.nvim_handler._rowsize = self._fnormal.metrics('linespace')


    def nvim_connect(self, *a, **k):
        ''' force connection to neovim '''
        self.nvim_handler.connect(*a, **k)
        self._nvimtk_config()

    @staticmethod
    def kill_all():
        ''' Kill all the neovim connections '''
        raise NotImplementedError
        for self in NvimTk.instances:
            if self.nvim_handler.connected:
                # Function hangs us..
                # self.after(1, self.nvim_handler._bridge.exit)
                self.nvim_handler._bridge.exit()


    def pack(self, *arg, **kwarg):
        ''' connect to neovim if required'''
        tk_util.Text.pack(self, *arg, **kwarg)
        if not self.nvim_handler.connected:
            self.nvim_connect()

        self.nvim_handler.bind_resize()


    def grid(self, *arg, **kwarg):
        ''' connect to neovim if required'''
        tk_util.Text.grid(self, *arg, **kwarg)
        if not self.nvim_handler.connected:
            self.nvim_connect()

        self.nvim_handler.bind_resize()


    def schedule_screen_update(self, apply_updates):
        '''This function is called from the bridge,
           apply_updates calls the required nvim actions'''
        # if time.time() - self.start_time > 1:
            # print()
        # self.start_time = time.time()
        def do():
            apply_updates()
            self.nvim_handler._flush()
            self.nvim_handler._start_blinking()
        self.master.after_idle(do)


    def quit(self):
        ''' destroy the widget, called from the bridge'''
        self.after_idle(self.destroy)

# if __name__ == '__main__':
    # main()