Completed
Push — master ( f6a238...df5f93 )
by timothy
01:08
created

MixTk   A

Complexity

Total Complexity 26

Size/Duplication

Total Lines 144
Duplicated Lines 0 %

Importance

Changes 15
Bugs 0 Features 1
Metric Value
c 15
b 0
f 1
dl 0
loc 144
rs 10
wmc 26

10 Methods

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