Completed
Push — master ( e78923...5c165f )
by timothy
01:28
created

parse_tk_state()   A

Complexity

Conditions 4

Size

Total Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

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