Completed
Push — master ( 1adca7...abca0a )
by timothy
01:10
created

MixNvim._nvim_resize()   B

Complexity

Conditions 2

Size

Total Lines 27

Duplication

Lines 0
Ratio 0 %

Importance

Changes 7
Bugs 0 Features 0
Metric Value
cc 2
c 7
b 0
f 0
dl 0
loc 27
rs 8.8571

1 Method

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