Completed
Push — master ( 9bf9ff...63635d )
by timothy
01:03
created

MixTk._tk_key()   B

Complexity

Conditions 6

Size

Total Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 8
Bugs 1 Features 1
Metric Value
cc 6
c 8
b 1
f 1
dl 0
loc 19
rs 8
1
'''
2
Implements a UI for neovim  using tkinter.
3
4
* The widget has lines updated/deleted so that any
5
  given time it only contains what is being displayed.
6
7
* The widget is filled with spaces
8
'''
9
10
import sys
11
import math
12
import time
13
from distutils.spawn import find_executable
14
from neovim import attach
15
16
from tkquick.gui.tools import rate_limited
17
18
from pytknvim.ui_bridge import UIBridge
19
from pytknvim.screen import Screen
20
from pytknvim.util import _stringify_key, _stringify_color
21
from pytknvim.util import _split_color, _invert_color
22
from pytknvim.util import debug_echo
23
from pytknvim import tk_util
24
25
try:
26
    import Tkinter as tk
27
    import tkFont as tkfont
28
    import ttk
29
except ImportError:
30
    import tkinter as tk
31
    import tkinter.font as tkfont
32
33
import attr
34
35
RESIZE_DELAY = 0.04
36
37
def parse_tk_state(state):
38
    if state & 0x4:
39
        return 'Ctrl'
40
    elif state & 0x8:
41
        return 'Alt'
42
    elif state & 0x1:
43
        return 'Shift'
44
45
46
tk_modifiers = ('Alt_L', 'Alt_R',
47
                'Control_L', 'Control_R',
48
                'Shift_L', 'Shift_R',
49
                'Win_L', 'Win_R')
50
51
52
KEY_TABLE = {
53
    'slash': '/',
54
    'backslash': '\\',
55
    'asciicircumf': '^',
56
    'at': '@',
57
    'numbersign': '#',
58
    'dollar': '$',
59
    'percent': '%',
60
    'ampersand': '&',
61
    'asterisk': '*',
62
    'parenleft': '(',
63
    'parenright': ')',
64
    'underscore': '_',
65
    'plus': '+',
66
    'minus': '-',
67
    'bracketleft': '[',
68
    'bracketright': ']',
69
    'braceleft': '{',
70
    'braceright': '}',
71
    'quotedbl': '"',
72
    'apostrophe': "'",
73
    'less': "<",
74
    'greater': ">",
75
    'comma': ",",
76
    'period': ".",
77
    'BackSpace': 'BS',
78
    'Return': 'CR',
79
    'Escape': 'Esc',
80
    'Delete': 'Del',
81
    'Next': 'PageUp',
82
    'Prior': 'PageDown',
83
    'Enter': 'CR',
84
}
85
86
87
class MixTk():
88
    '''
89
    Tkinter actions we bind and use to communicate to neovim
90
    '''
91
    def tk_key_pressed(self,event, **k):
92
        keysym = event.keysym
93
        state = parse_tk_state(event.state)
94
        if event.char not in ('', ' ') \
95
                    and state in (None, 'Shift'):
96
            if event.keysym_num == ord(event.char):
97
                # Send through normal keys
98
                self._bridge.input(event.char)
99
                return
100
        if keysym in tk_modifiers:
101
            # We don't need to track the state of modifier bits
102
            return
103
        if keysym.startswith('KP_'):
104
            keysym = keysym[3:]
105
106
        # Translated so vim understands
107
        input_str = _stringify_key( KEY_TABLE.get(keysym, keysym), state)
108
        self._bridge.input(input_str)
109
110
111
    def _tk_quit(self, *args):
112
        self._bridge.exit()
113
114
115
    @rate_limited(1/RESIZE_DELAY, mode='kill')
116
    def _tk_resize(self, event):
117
        '''Let Neovim know we are changing size'''
118
        cols = int(math.floor(event.width / self._colsize))
119
        rows = int(math.floor(event.height / self._rowsize))
120
        if self._screen.columns == cols:
121
            if self._screen.rows == rows:
122
                return
123
        self.current_cols = cols
124
        self.current_rows = rows
125
        self._bridge.resize(cols, rows)
126
        if self.debug_echo:
127
            print('resizing c, r, w, h',
128
                    cols,rows, event.width, event.height)
129
130
131
    def bind_resize(self):
132
        '''
133
        after calling,
134
        widget changes will now be passed along to neovim
135
        '''
136
        print('binding resize to', self, self.text)
137
        self._configure_id = self.text.bind('<Configure>', self._tk_resize)
138
139
140
    def unbind_resize(self):
141
        '''
142
        after calling,
143
        widget size changes will not be passed along to nvim
144
        '''
145
        print('unbinding resize from', self)
146
        self.text.unbind('<Configure>', self._configure_id)
147
148
149
    def _get_row(self, screen_row):
150
        '''change a screen row to a tkinter row,
151
        defaults to screen.row'''
152
        if screen_row is None:
153
            screen_row = self._screen.row
154
        return screen_row + 1
155
156
157
    def _get_col(self, screen_col):
158
        '''change a screen col to a tkinter row,
159
        defaults to screen.col'''
160
        if screen_col is None:
161
            screen_col = self._screen.col
162
        return screen_col
163
164
165
    def tk_delete_line(self, screen_col=None, screen_row=None,
166
                                       del_eol=False, count=1):
167
        '''
168
        To specifiy where to start the delete from
169
        screen_col (defualts to screen.row)
170
        screen_row (defaults to screen.col)
171
172
        To delete the eol char aswell
173
        del_eol (defaults to False)
174
175
        count is the number of lines to delete
176
        '''
177
        line = self._get_row(screen_row)
178
        col = self._get_col(screen_col)
179
        start = "%d.%d" % (line, col)
180
        if del_eol:
181
            end = "%d.0" % (line + count)
182
        else:
183
            end = "%d.end" % (line + count - 1)
184
        self.text.delete(start, end)
185
        gotten = self.text.get(start, end)
186
        if self.debug_echo == True:
187
            print('deleted  from ' + start + ' to end ' +end)
188
            print('deleted '+repr(gotten))
189
190
191
    def tk_pad_line(self, screen_col=None, add_eol=False,
192
                                    screen_row=None, count=1):
193
        '''
194
        add required blank spaces at the end of the line
195
        can apply action to multiple rows py passing a count
196
        in
197
        '''
198
        line = self._get_row(screen_row)
199
        col = self._get_col(screen_col)
200
        for n in range(0, count):
201
            start = "%d.%d" % (line + n, col)
202
            spaces = " " * (self.current_cols - col)
203
            if add_eol:
204
                spaces += '\n'
205
            if self.debug_echo:
206
                pass
207
                # print('padding from ', start, ' with %d: '
208
                                                # % len(spaces))
209
                # print(repr(spaces))
210
            self.text.insert(start, spaces)
211
212
213
    def _start_blinking(self):
214
        # cursor is drawn seperatley in the window
215
        row, col = self._screen.row, self._screen.col
216
        text, attrs = self._screen.get_cursor()
217
        pos = "%d.%d" % (row +1, col)
218
219
        if not attrs:
220
            attrs = self._get_tk_attrs(None)
221
        fg = attrs[1].get('foreground')
222
        bg = attrs[1].get('background')
223
        try:
224
            self.text.stop_blink()
225
        except Exception:
226
            pass
227
        self.text.blink_cursor(pos, fg, bg)
228
229
230
class NvimHandler(MixTk):
231
    '''These methods get called by neovim'''
232
233
    def __init__(self, text, toplevel, address, debug_echo):
234
        self.text = text
235
        self.toplevel = toplevel
236
        self.debug_echo = debug_echo
237
238
        self._insert_cursor = False
239
        self._screen = None
240
        self._foreground = -1
241
        self._background = -1
242
        self._pending = [0,0,0]
243
        self._attrs = {}
244
        self._reset_attrs_cache()
245
        self._colsize = None
246
        self._rowsize = None
247
248
        # Have we connected to an nvim instance?
249
        self.connected = False
250
        # Connecition Info for neovim
251
        self.address = address
252
        cols = 80
253
        rows = 24
254
        self.current_cols = cols
255
        self.current_rows = rows
256
257
        self._screen = Screen(cols, rows)
258
        self._bridge = UIBridge()
259
260
    @debug_echo
261
    def connect(self):
262
        if self.address:
263
            nvim = attach('socket', path=self.address)
264
        else:
265
            nvim_binary = find_executable('nvim')
266
            args = [nvim_binary, '--embed']
267
            # args.extend(['-u', 'NONE'])
268
            nvim = attach('child', argv=args)
269
270
        self._bridge.connect(nvim, self.text)
271
        self._screen = Screen(self.current_cols, self.current_rows)
272
        self._bridge.attach(self.current_cols, self.current_rows, rgb=True)
273
        # if len(sys.argv) > 1:
274
            # nvim.command('edit ' + sys.argv[1])
275
        self.connected = True
276
277
    @debug_echo
278
    def _nvim_resize(self, cols, rows):
279
        '''Let neovim update tkinter when neovim changes size'''
280
        # TODO
281
        # Make sure it works when user changes font,
282
        # only can support mono font i think..
283
        self._screen = Screen(cols, rows)
284
285
    @debug_echo
286
    def _nvim_clear(self):
287
        '''
288
        wipe everyything, even the ~ and status bar
289
        '''
290
        self._screen.clear()
291
292
        self.tk_delete_line(del_eol=True,
293
                            screen_row=0,
294
                            screen_col=0,
295
                            count=self.current_rows)
296
        # Add spaces everywhere
297
        self.tk_pad_line(screen_row=0,
298
                         screen_col=0,
299
                         count=self.current_rows,
300
                         add_eol=True,)
301
302
303
    @debug_echo
304
    def _nvim_eol_clear(self):
305
        '''
306
        delete from index to end of line,
307
        fill with whitespace
308
        leave eol intact
309
        '''
310
        self._screen.eol_clear()
311
        self.tk_delete_line(del_eol=False)
312
        self.tk_pad_line(screen_col=self._screen.col,
313
                         add_eol=False)
314
315
316
    @debug_echo
317
    def _nvim_cursor_goto(self, row, col):
318
        '''Move gui cursor to position'''
319
        self._screen.cursor_goto(row, col)
320
        self.text.see("1.0")
321
322
323
    @debug_echo
324
    def _nvim_busy_start(self):
325
        self._busy = True
326
327
328
    @debug_echo
329
    def _nvim_busy_stop(self):
330
        self._busy = False
331
332
333
    @debug_echo
334
    def _nvim_mouse_on(self):
335
        self.mouse_enabled = True
336
337
338
    @debug_echo
339
    def _nvim_mouse_off(self):
340
        self.mouse_enabled = False
341
342
343
    @debug_echo
344
    def _nvim_mode_change(self, mode):
345
        self._insert_cursor = mode == 'insert'
346
347
348
    @debug_echo
349
    def _nvim_set_scroll_region(self, top, bot, left, right):
350
        self._screen.set_scroll_region(top, bot, left, right)
351
352
353
    @debug_echo
354
    def _nvim_scroll(self, count):
355
        self._flush()
356
        self._screen.scroll(count)
357
        abs_count = abs(count)
358
        # The minus 1 is because we want our tk_* functions
359
        # to operate on the row passed in
360
        delta = abs_count - 1
361
        # Down
362
        if count > 0:
363
            delete_row = self._screen.top
364
            pad_row = self._screen.bot - delta
365
        # Up
366
        else:
367
            delete_row = self._screen.bot - delta
368
            pad_row = self._screen.top
369
370
        self.tk_delete_line(screen_row=delete_row,
371
                            screen_col=0,
372
                            del_eol=True,
373
                            count=abs_count)
374
        self.tk_pad_line(screen_row=pad_row,
375
                         screen_col=0,
376
                         add_eol=True,
377
                         count=abs_count)
378
        # self.text.yview_scroll(count, 'units')
379
380
381
    # @debug_echo
382
    def _nvim_highlight_set(self, attrs):
383
        self._attrs = self._get_tk_attrs(attrs)
384
385
386
    # @debug_echo
387
    def _reset_attrs_cache(self):
388
        self._tk_text_cache = {}
389
        self._tk_attrs_cache = {}
390
391
392
    @debug_echo
393
    def _get_tk_attrs(self, attrs):
394
        key = tuple(sorted((k, v,) for k, v in (attrs or {}).items()))
395
        rv = self._tk_attrs_cache.get(key, None)
396
        if rv is None:
397
            fg = self._foreground if self._foreground != -1\
398
                                                else 0
399
            bg = self._background if self._background != -1\
400
                                                else 0xffffff
401
            n = {'foreground': _split_color(fg),
402
                'background': _split_color(bg),}
403
            if attrs:
404
                # make sure that fg and bg are assigned first
405
                for k in ['foreground', 'background']:
406
                    if k in attrs:
407
                        n[k] = _split_color(attrs[k])
408
                for k, v in attrs.items():
409
                    if k == 'reverse':
410
                        n['foreground'], n['background'] = \
411
                            n['background'], n['foreground']
412
                    elif k == 'italic':
413
                        n['slant'] = 'italic'
414
                    elif k == 'bold':
415
                        n['weight'] = 'bold'
416
                        # TODO
417
                        # if self._bold_spacing:
418
                            # n['letter_spacing'] \
419
                                    # = str(self._bold_spacing)
420
                    elif k == 'underline':
421
                        n['underline'] = '1'
422
            c = dict(n)
423
            c['foreground'] = _invert_color(*_split_color(fg))
424
            c['background'] = _invert_color(*_split_color(bg))
425
            c['foreground'] = _stringify_color(*c['foreground'])
426
            c['background'] = _stringify_color(*c['background'])
427
            n['foreground'] = _stringify_color(*n['foreground'])
428
            n['background'] = _stringify_color(*n['background'])
429
            # n = normal, c = cursor
430
            rv = (n, c)
431
            self._tk_attrs_cache[key] = (n, c)
432
        return rv
433
434
435
    # @debug_echo
436
    def _nvim_put(self, text):
437
        '''
438
        put a charachter into position, we only write the lines
439
        when a new row is being edited
440
        '''
441
        if self._screen.row != self._pending[0]:
442
            # write to screen if vim puts stuff on  a new line
443
            self._flush()
444
445
        self._screen.put(text, self._attrs)
446
        self._pending[1] = min(self._screen.col - 1,
447
                               self._pending[1])
448
        self._pending[2] = max(self._screen.col,
449
                               self._pending[2])
450
451
452
    # @debug_echo
453
    def _nvim_bell(self):
454
        pass
455
456
457
    # @debug_echo
458
    def _nvim_visual_bell(self):
459
        pass
460
461
462
    # @debug_echo
463
    def _nvim_update_fg(self, fg):
464
        self._foreground = fg
465
        self._reset_attrs_cache()
466
        foreground = self._get_tk_attrs(None)[0]['foreground']
467
        self.text.config(foreground=foreground)
468
469
470
    # @debug_echo
471
    def _nvim_update_bg(self, bg):
472
        self._background = bg
473
        self._reset_attrs_cache()
474
        background = self._get_tk_attrs(None)[0]['background']
475
        self.text.config(background=background)
476
477
478
    # @debug_echo
479
    def _nvim_update_suspend(self, arg):
480
        self.root.iconify()
481
482
483
    # @debug_echo
484
    def _nvim_set_title(self, title):
485
        self.root.title(title)
486
487
488
    # @debug_echo
489
    def _nvim_set_icon(self, icon):
490
        self._icon = tk.PhotoImage(file=icon)
491
        self.root.tk.call('wm', 'iconphoto',
492
                          self.root._w, self._icon)
493
494
495
    # @debug_echo
496
    def _flush(self):
497
        row, startcol, endcol = self._pending
498
        self._pending[0] = self._screen.row
499
        self._pending[1] = self._screen.col
500
        self._pending[2] = self._screen.col
501
        if startcol == endcol:
502
            #print('startcol is endcol return, row %s col %s'% (self._screen.row, self._screen.col))
503
            return
504
        ccol = startcol
505
        buf = []
506
        bold = False
507
        for _, col, text, attrs in self._screen.iter(row,
508
                                    row, startcol, endcol - 1):
509
            newbold = attrs and 'bold' in attrs[0]
510
            if newbold != bold or not text:
511
                if buf:
512
                    self._draw(row, ccol, buf)
513
                bold = newbold
514
                buf = [(text, attrs,)]
515
                ccol = col
516
            else:
517
                buf.append((text, attrs,))
518
        if buf:
519
            self._draw(row, ccol, buf)
520
        else:
521
            pass
522
            # print('flush with no draw')
523
524
525
    @debug_echo
526
    def _draw(self, row, col, data):
527
        '''
528
        updates a line :)
529
        '''
530
        for text, attrs in data:
531
            try:
532
                start = end
533
            except UnboundLocalError:
534
                start = "{}.{}".format(row + 1, col)
535
            end = start+'+{0}c'.format(len(text))
536
537
            if not attrs:
538
                attrs = self._get_tk_attrs(None)
539
            attrs = attrs[0]
540
541
            # if self.debug_echo:
542
                # print('replacing ', repr(self.text.get(start, end)))
543
                # print('with ', repr(text), ' at ', start, ' ',end)
544
            self.text.replace(start, end, text)
545
546
            if attrs:
547
                self.text.apply_attribute(attrs, start, end)
548
            start
549
550
551
    @debug_echo
552
    def _nvim_exit(self, arg):
553
        print('in exit')
554
        import pdb;pdb.set_trace()
555
        # self.root.destroy()
556
557
    @debug_echo
558
    def _nvim_update_sp(self, *args):
559
        pass
560
561
562
class NvimTk(tk_util.Text):
563
    '''namespace for neovim related methods,
564
    requests are generally prefixed with _tk_,
565
    responses are prefixed with _nvim_
566
    '''
567
    # we get keys, mouse movements inside tkinter, using binds,
568
    # These binds are handed off to neovim using _input
569
570
    # Neovim interpruts the actions and calls certain
571
    # functions which are defined and implemented in tk
572
573
    # The api from neovim does stuff line by line,
574
    # so each callback from neovim produces a series
575
    # of miniscule actions which in the end updates a line
576
577
    # So we can shutdown the neovim connections
578
    instances = []
579
580
    def __init__(self, parent, *_, address=False, toplevel=False, **kwargs):
581
        '''
582
        :parent: normal tkinter parent or master of the widget
583
        :toplevel: , if true will resize based off the toplevel etc
584
        :address: neovim connection info
585
            named pipe /tmp/nvim/1231
586
            tcp/ip socket 127.0.0.1:4444
587
            'child'
588
            'headless'
589
        :kwargs: config options for text widget
590
        '''
591
        tk_util.Text.__init__(self, parent, **kwargs)
592
        self.nvim_handler = NvimHandler(text=self,
593
                                        toplevel=toplevel,
594
                                        address=address,
595
                                        debug_echo=False)
596
597
        # TODO weak ref?
598
        NvimTk.instances.append(self)
599
600
    def _nvimtk_config(self, *args):
601
        '''required config'''
602
        # Hide tkinter cursor
603
        self.config(insertontime=0)
604
605
        # Remove Default Bindings and what happens on insert etc
606
        bindtags = list(self.bindtags())
607
        bindtags.remove("Text")
608
        self.bindtags(tuple(bindtags))
609
610
        self.bind('<Key>', self.nvim_handler.tk_key_pressed)
611
612
        self.bind('<Button-1>', lambda e: self.focus_set())
613
614
        # The negative number makes it pixels instead of point sizes
615
        size = self.make_font_size(13)
616
        self._fnormal = tkfont.Font(family='Monospace', size=size)
617
        self._fbold = tkfont.Font(family='Monospace', weight='bold', size=size)
618
        self._fitalic = tkfont.Font(family='Monospace', slant='italic', size=size)
619
        self._fbolditalic = tkfont.Font(family='Monospace', weight='bold',
620
                                 slant='italic', size=size)
621
        self.config(font=self._fnormal, wrap=tk.NONE)
622
623
        self.nvim_handler._colsize = self._fnormal.measure('M')
624
        self.nvim_handler._rowsize = self._fnormal.metrics('linespace')
625
626
627
    def nvim_connect(self):
628
        ''' force connection to neovim '''
629
        self.nvim_handler.connect()
630
        self._nvimtk_config()
631
632
    @staticmethod
633
    def kill_all():
634
        ''' Kill all the neovim connections '''
635
        raise NotImplementedError
636
        for self in NvimTk.instances:
637
            if self.nvim_handler.connected:
638
                # Function hangs us..
639
                # self.after(1, self.nvim_handler._bridge.exit)
640
                self.nvim_handler._bridge.exit()
641
642
643
    def pack(self, *arg, **kwarg):
644
        tk_util.Text.pack(self, *arg, **kwarg)
645
        if not self.nvim_handler.connected:
646
            self.nvim_connect()
647
648
        self.nvim_handler.bind_resize()
649
650
651
    def grid(self, *arg, **kwarg):
652
        tk_util.Text.grid(self, *arg, **kwarg)
653
        if not self.nvim_handler.connected:
654
            self.nvim_connect()
655
656
        self.nvim_handler.bind_resize()
657
658
659
    def schedule_screen_update(self, apply_updates):
660
        '''This function is called from the bridge,
661
           apply_updates calls the required nvim actions'''
662
        # if time.time() - self.start_time > 1:
663
            # print()
664
        # self.start_time = time.time()
665
        def do():
666
            apply_updates()
667
            self.nvim_handler._flush()
668
            self.nvim_handler._start_blinking()
669
        self.master.after_idle(do)
670
671
672
    def quit(self):
673
        ''' destroy the widget, called from the bridge'''
674
        self.after_idle(self.destroy)
675
676
# if __name__ == '__main__':
677
    # main()
678