├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── demo.png ├── pytknvim ├── __init__.py ├── example.py ├── screen.py ├── tarruda.py ├── test_canvas.py ├── test_screen.py ├── tests │ ├── __init__.py │ ├── test_file │ ├── test_integration.py │ └── util.py ├── tk_canvas.py ├── tk_ui.py ├── tk_util.py ├── ui_bridge.py └── util.py ├── setup.cfg └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | tags 2 | .cache/ 3 | *.txt 4 | *.pyc 5 | *.pyo 6 | __pycache__/ 7 | *.sw* 8 | *.*~ 9 | .idea/ 10 | *.egg-info 11 | build/ 12 | dist/ 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: True 2 | 3 | language: python 4 | python: 5 | - "3.5" 6 | 7 | install: 8 | # - "apt-get install software-properties-common" 9 | - "sudo add-apt-repository ppa:neovim-ppa/unstable -y" 10 | - "sudo apt-get update -y" 11 | - "sudo apt-get install neovim -y" 12 | - "pip install neovim" 13 | - "pip install coveralls" 14 | - "pip install coverage" 15 | 16 | # For Testing with gui 17 | before_script: 18 | - "export DISPLAY=:99.0" 19 | - "sh -e /etc/init.d/xvfb start" 20 | - sleep 3 # give xvfb some time to start 21 | - cd pytknvim 22 | 23 | script: 24 | - coverage run --source=tests -m py.test -k "not failing" 25 | 26 | after_success: 27 | - coveralls 28 | 29 | notifications: 30 | - email: false 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, timothy eichler 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ### Python Tkinter gui for [Neovim](https://github.com/neovim/neovim) 3 | 4 | [![Build Status](https://travis-ci.org/timeyyy/pytknvim.svg?branch=master)](https://travis-ci.org/timeyyy/pytknvim) 5 | [![Coverage Status](https://coveralls.io/repos/github/timeyyy/pytknvim/badge.svg?branch=master)](https://coveralls.io/github/timeyyy/pytknvim?branch=master) 6 | 7 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/timeyyy/pytknvim/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/timeyyy/pytknvim/?branch=master) 8 | [![Code Climate](https://codeclimate.com/github/timeyyy/pytknvim/badges/gpa.svg)](https://codeclimate.com/github/timeyyy/pytknvim) 9 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/9834369c3abe49fdaa4eebcc7239d17d)](https://www.codacy.com/app/timeyyy\_da\_man/pytknvim?utm\_source=github.com&utm\_medium=referral&utm\_content=timeyyy/pytknvim&utm\_campaign=Badge\_Grade) 10 | [![Code Issues](https://www.quantifiedcode.com/api/v1/project/6c355ec860204274b165a15393aadacf/badge.svg)](https://www.quantifiedcode.com/app/project/6c355ec860204274b165a15393aadacf) 11 | 12 | Simple nvim gui implemented using tkinter 13 | 14 | #### Missing Features 15 | 16 | - **Vertical splits (Requires conversion to Canvas widget or externalized windows from nvim)** 17 | - Using Windows/Super key for modifiers.(should be trivial...) 18 | - Python2 not supported (should be trivial to add) 19 | - Stability.. a bit flaky on startup 20 | 21 | #### Installation 22 | 23 | ```sh 24 | pip install pytknvim 25 | ``` 26 | 27 | #### Usage 28 | 29 | ```sh 30 | pytknvim 31 | ``` 32 | 33 | ### Screenshot 34 | 35 | ![DEMO](demo.png?raw=true) 36 | -------------------------------------------------------------------------------- /demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timeyyy/pytknvim/d5fae053c3d39ded52afeb76b4d0d4d0e28ed20c/demo.png -------------------------------------------------------------------------------- /pytknvim/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timeyyy/pytknvim/d5fae053c3d39ded52afeb76b4d0d4d0e28ed20c/pytknvim/__init__.py -------------------------------------------------------------------------------- /pytknvim/example.py: -------------------------------------------------------------------------------- 1 | 2 | import tkinter as tk 3 | 4 | from tk_ui import NvimTk 5 | 6 | root = tk.Tk() 7 | 8 | def ex1(): 9 | tf = tk.Frame() 10 | tf.pack(side=tk.TOP) 11 | but = tk.Button(tf, text='i take focus') 12 | but.config(command=lambda: but.focus_set()) 13 | but.pack() 14 | 15 | left = NvimTk(root) 16 | # Force Connection 17 | left.nvim_connect() 18 | left.pack(side=tk.LEFT) 19 | 20 | right = NvimTk(root) 21 | # Connection happens implicitly on packing or grdiing if required 22 | right.pack(side=tk.RIGHT, fill='both', expand=1) 23 | 24 | 25 | def ex2(): 26 | # Toplevel example 27 | text = NvimTk(root) 28 | # text.nvim_connect('-u', 'NONE') 29 | text.pack(expand=1, fill='both') 30 | 31 | # ex1() 32 | ex2() 33 | # root.protocol('WM_DELETE_WINDOW', NvimTk.kill_all) 34 | root.mainloop() 35 | 36 | 37 | -------------------------------------------------------------------------------- /pytknvim/screen.py: -------------------------------------------------------------------------------- 1 | """Common code for graphical and text UIs.""" 2 | from neovim.compat import IS_PYTHON3 3 | 4 | 5 | __all__ = ('Screen',) 6 | 7 | 8 | if not IS_PYTHON3: 9 | range = xrange # NOQA 10 | 11 | 12 | class Cell(object): 13 | def __init__(self): 14 | self.text = ' ' 15 | self.attrs = None 16 | 17 | def __repr__(self): 18 | return self.text 19 | 20 | def get(self): 21 | return self.text, self.attrs 22 | 23 | def set(self, text, attrs): 24 | self.text = text 25 | self.attrs = attrs 26 | 27 | def copy(self, other): 28 | other.text = self.text 29 | other.attrs = self.attrs 30 | 31 | 32 | class Screen(object): 33 | 34 | """Store nvim screen state.""" 35 | 36 | def __init__(self, columns, rows): 37 | """Initialize the Screen instance.""" 38 | self.columns = columns 39 | self.rows = rows 40 | self.row = 0 41 | self.col = 0 42 | self.top = 0 43 | self.bot = rows - 1 44 | self.left = 0 45 | self.right = columns - 1 46 | self._cells = [[Cell() for c in range(columns)] for r in range(rows)] 47 | 48 | def clear(self): 49 | """Clear the screen.""" 50 | self._clear_region(self.top, self.bot, self.left, self.right) 51 | 52 | def eol_clear(self): 53 | """Clear from the cursor position to the end of the scroll region.""" 54 | self._clear_region(self.row, self.row, self.col, self.right) 55 | 56 | def cursor_goto(self, row, col): 57 | """Change the virtual cursor position.""" 58 | self.row = row 59 | self.col = col 60 | 61 | def set_scroll_region(self, top, bot, left, right): 62 | """Change scroll region.""" 63 | self.top = top 64 | self.bot = bot 65 | self.left = left 66 | self.right = right 67 | 68 | def scroll(self, count): 69 | """Shift scroll region.""" 70 | top, bot = self.top, self.bot 71 | left, right = self.left, self.right 72 | if count > 0: 73 | start = top 74 | stop = bot - count + 1 75 | step = 1 76 | else: 77 | start = bot 78 | stop = top - count - 1 79 | step = -1 80 | # shift the cells 81 | for row in range(start, stop, step): 82 | target_row = self._cells[row] 83 | source_row = self._cells[row + count] 84 | for col in range(left, right + 1): 85 | tgt = target_row[col] 86 | source_row[col].copy(tgt) 87 | # clear invalid cells 88 | for row in range(stop, stop + count, step): 89 | self._clear_region(row, row, left, right) 90 | 91 | def put(self, text, attrs): 92 | """Put character on virtual cursor position.""" 93 | cell = self._cells[self.row][self.col] 94 | cell.set(text, attrs) 95 | self.cursor_goto(self.row, self.col + 1) 96 | 97 | def get_cell(self, row, col): 98 | """Get text, attrs at row, col.""" 99 | return self._cells[row][col].get() 100 | 101 | def get_cursor(self): 102 | """Get text, attrs at the virtual cursor position.""" 103 | return self.get_cell(self.row, self.col) 104 | 105 | def iter(self, startrow, endrow, startcol, endcol): 106 | """Extract text/attrs at row, startcol-endcol.""" 107 | for row in range(startrow, endrow + 1): 108 | r = self._cells[row] 109 | cell = r[startcol] 110 | curcol = startcol 111 | attrs = cell.attrs 112 | buf = [cell.text] 113 | for col in range(startcol + 1, endcol + 1): 114 | cell = r[col] 115 | if cell.attrs != attrs or not cell.text: 116 | yield row, curcol, ''.join(buf), attrs 117 | attrs = cell.attrs 118 | buf = [cell.text] 119 | curcol = col 120 | if not cell.text: 121 | # glyph uses two cells, yield a separate entry 122 | yield row, curcol, '', None 123 | curcol += 1 124 | else: 125 | buf.append(cell.text) 126 | if buf: 127 | yield row, curcol, ''.join(buf), attrs 128 | 129 | def _clear_region(self, top, bot, left, right): 130 | for rownum in range(top, bot + 1): 131 | row = self._cells[rownum] 132 | for colnum in range(left, right + 1): 133 | cell = row[colnum] 134 | cell.set(' ', None) 135 | -------------------------------------------------------------------------------- /pytknvim/tarruda.py: -------------------------------------------------------------------------------- 1 | """Neovim TKinter UI.""" 2 | import sys 3 | from Tkinter import Canvas, Tk 4 | from collections import deque 5 | from threading import Thread 6 | # import StringIO, cProfile, pstats 7 | 8 | from neovim import attach 9 | 10 | from tkFont import Font 11 | 12 | SPECIAL_KEYS = { 13 | 'Escape': 'Esc', 14 | 'Return': 'CR', 15 | 'BackSpace': 'BS', 16 | 'Prior': 'PageUp', 17 | 'Next': 'PageDown', 18 | 'Delete': 'Del', 19 | } 20 | 21 | 22 | if sys.version_info < (3, 0): 23 | range = xrange 24 | 25 | 26 | class NvimTk(object): 27 | 28 | """Wraps all nvim/tk event handling.""" 29 | 30 | def __init__(self, nvim): 31 | """Initialize with a Nvim instance.""" 32 | self._nvim = nvim 33 | self._attrs = {} 34 | self._nvim_updates = deque() 35 | self._canvas = None 36 | self._fg = '#000000' 37 | self._bg = '#ffffff' 38 | 39 | def run(self): 40 | """Start the UI.""" 41 | self._tk_setup() 42 | t = Thread(target=self._nvim_event_loop) 43 | t.daemon = True 44 | t.start() 45 | self._root.mainloop() 46 | 47 | def _tk_setup(self): 48 | self._root = Tk() 49 | self._root.bind('<>', self._tk_nvim_redraw) 50 | self._root.bind('<>', self._tk_nvim_detach) 51 | self._root.bind('', self._tk_key) 52 | 53 | def _tk_nvim_redraw(self, *args): 54 | update = self._nvim_updates.popleft() 55 | for update in update: 56 | handler = getattr(self, '_tk_nvim_' + update[0]) 57 | for args in update[1:]: 58 | handler(*args) 59 | 60 | def _tk_nvim_detach(self, *args): 61 | self._root.destroy() 62 | 63 | def _tk_nvim_resize(self, width, height): 64 | self._tk_redraw_canvas(width, height) 65 | 66 | def _tk_nvim_clear(self): 67 | self._tk_clear_region(0, self._height - 1, 0, self._width - 1) 68 | 69 | def _tk_nvim_eol_clear(self): 70 | row, col = (self._cursor_row, self._cursor_col,) 71 | self._tk_clear_region(row, row, col, self._scroll_right) 72 | 73 | def _tk_nvim_cursor_goto(self, row, col): 74 | self._cursor_row = row 75 | self._cursor_col = col 76 | 77 | def _tk_nvim_cursor_on(self): 78 | pass 79 | 80 | def _tk_nvim_cursor_off(self): 81 | pass 82 | 83 | def _tk_nvim_mouse_on(self): 84 | pass 85 | 86 | def _tk_nvim_mouse_off(self): 87 | pass 88 | 89 | def _tk_nvim_insert_mode(self): 90 | pass 91 | 92 | def _tk_nvim_normal_mode(self): 93 | pass 94 | 95 | def _tk_nvim_set_scroll_region(self, top, bot, left, right): 96 | self._scroll_top = top 97 | self._scroll_bot = bot 98 | self._scroll_left = left 99 | self._scroll_right = right 100 | 101 | def _tk_nvim_scroll(self, count): 102 | top, bot = (self._scroll_top, self._scroll_bot,) 103 | left, right = (self._scroll_left, self._scroll_right,) 104 | 105 | if count > 0: 106 | destroy_top = top 107 | destroy_bot = top + count - 1 108 | move_top = destroy_bot + 1 109 | move_bot = bot 110 | fill_top = move_bot + 1 111 | fill_bot = fill_top + count - 1 112 | else: 113 | destroy_top = bot + count + 1 114 | destroy_bot = bot 115 | move_top = top 116 | move_bot = destroy_top - 1 117 | fill_bot = move_top - 1 118 | fill_top = fill_bot + count + 1 119 | 120 | # destroy items that would be moved outside the scroll region after 121 | # scrolling 122 | # self._tk_clear_region(destroy_top, destroy_bot, left, right) 123 | # self._tk_clear_region(move_top, move_bot, left, right) 124 | self._tk_destroy_region(destroy_top, destroy_bot, left, right) 125 | self._tk_tag_region('move', move_top, move_bot, left, right) 126 | self._canvas.move('move', 0, -count * self._rowsize) 127 | self._canvas.dtag('move', 'move') 128 | # self._tk_fill_region(fill_top, fill_bot, left, right) 129 | 130 | def _tk_nvim_highlight_set(self, attrs): 131 | self._attrs = attrs 132 | 133 | def _tk_nvim_put(self, data): 134 | # choose a Font instance 135 | font = self._fnormal 136 | if self._attrs.get('bold', False): 137 | font = self._fbold 138 | if self._attrs.get('italic', False): 139 | font = self._fbolditalic if font == self._fbold else self._fitalic 140 | # colors 141 | fg = "#{0:0{1}x}".format(self._attrs.get('foreground', self._fg), 6) 142 | bg = "#{0:0{1}x}".format(self._attrs.get('background', self._bg), 6) 143 | # get the "text" and "rect" which correspond to the current cell 144 | x, y = self._tk_get_coords(self._cursor_row, self._cursor_col) 145 | items = self._canvas.find_overlapping(x, y, x + 1, y + 1) 146 | if len(items) != 2: 147 | # caught part the double-width character in the cell to the left, 148 | # filter items which dont have the same horizontal coordinate as 149 | # "x" 150 | predicate = lambda item: self._canvas.coords(item)[0] == x 151 | items = filter(predicate, items) 152 | # rect has lower id than text, sort to unpack correctly 153 | rect, text = sorted(items) 154 | self._canvas.itemconfig(text, fill=fg, font=font, text=data or ' ') 155 | self._canvas.itemconfig(rect, fill=bg) 156 | self._tk_nvim_cursor_goto(self._cursor_row, self._cursor_col + 1) 157 | 158 | def _tk_nvim_bell(self): 159 | self._root.bell() 160 | 161 | def _tk_nvim_update_fg(self, fg): 162 | self._fg = "#{0:0{1}x}".format(fg, 6) 163 | 164 | def _tk_nvim_update_bg(self, bg): 165 | self._bg = "#{0:0{1}x}".format(bg, 6) 166 | 167 | def _tk_redraw_canvas(self, width, height): 168 | if self._canvas: 169 | self._canvas.destroy() 170 | self._fnormal = Font(family='Monospace', size=13) 171 | self._fbold = Font(family='Monospace', weight='bold', size=13) 172 | self._fitalic = Font(family='Monospace', slant='italic', size=13) 173 | self._fbolditalic = Font(family='Monospace', weight='bold', 174 | slant='italic', size=13) 175 | self._colsize = self._fnormal.measure('A') 176 | self._rowsize = self._fnormal.metrics('linespace') 177 | self._canvas = Canvas(self._root, width=self._colsize * width, 178 | height=self._rowsize * height) 179 | self._tk_fill_region(0, height - 1, 0, width - 1) 180 | self._cursor_row = 0 181 | self._cursor_col = 0 182 | self._scroll_top = 0 183 | self._scroll_bot = height - 1 184 | self._scroll_left = 0 185 | self._scroll_right = width - 1 186 | self._width, self._height = (width, height,) 187 | self._canvas.pack() 188 | 189 | def _tk_fill_region(self, top, bot, left, right): 190 | # create columns from right to left so the left columns have a 191 | # higher z-index than the right columns. This is required to 192 | # properly display characters that cross cell boundary 193 | for rownum in range(bot, top - 1, -1): 194 | for colnum in range(right, left - 1, -1): 195 | x1 = colnum * self._colsize 196 | y1 = rownum * self._rowsize 197 | x2 = (colnum + 1) * self._colsize 198 | y2 = (rownum + 1) * self._rowsize 199 | # for each cell, create two items: The rectangle is used for 200 | # filling background and the text is for cell contents. 201 | self._canvas.create_rectangle(x1, y1, x2, y2, 202 | fill=self._background, width=0) 203 | self._canvas.create_text(x1, y1, anchor='nw', 204 | font=self._fnormal, width=1, 205 | fill=self._foreground, text=' ') 206 | 207 | def _tk_clear_region(self, top, bot, left, right): 208 | self._tk_tag_region('clear', top, bot, left, right) 209 | self._canvas.itemconfig('clear', fill=self._bg) 210 | self._canvas.dtag('clear', 'clear') 211 | 212 | def _tk_destroy_region(self, top, bot, left, right): 213 | self._tk_tag_region('destroy', top, bot, left, right) 214 | self._canvas.delete('destroy') 215 | self._canvas.dtag('destroy', 'destroy') 216 | 217 | def _tk_tag_region(self, tag, top, bot, left, right): 218 | x1, y1 = self._tk_get_coords(top, left) 219 | x2, y2 = self._tk_get_coords(bot, right) 220 | self._canvas.addtag_overlapping(tag, x1, y1, x2 + 1, y2 + 1) 221 | 222 | def _tk_get_coords(self, row, col): 223 | x = col * self._colsize 224 | y = row * self._rowsize 225 | return x, y 226 | 227 | def _tk_key(self, event): 228 | if 0xffe1 <= event.keysym_num <= 0xffee: 229 | # this is a modifier key, ignore. Source: 230 | # https://www.tcl.tk/man/tcl8.4/TkCmd/keysyms.htm 231 | return 232 | # Translate to Nvim representation of keys 233 | send = [] 234 | if event.state & 0x1: 235 | send.append('S') 236 | if event.state & 0x4: 237 | send.append('C') 238 | if event.state & (0x8 | 0x80): 239 | send.append('A') 240 | special = len(send) > 0 241 | key = event.char 242 | if _is_invalid_key(key): 243 | special = True 244 | key = event.keysym 245 | send.append(SPECIAL_KEYS.get(key, key)) 246 | send = '-'.join(send) 247 | if special: 248 | send = '<' + send + '>' 249 | nvim = self._nvim 250 | nvim.session.threadsafe_call(lambda: nvim.input(send)) 251 | 252 | def _nvim_event_loop(self): 253 | self._nvim.session.run(self._nvim_request, 254 | self._nvim_notification, 255 | lambda: self._nvim.attach_ui(80, 24)) 256 | self._root.event_generate('<>', when='tail') 257 | 258 | def _nvim_request(self, method, args): 259 | raise Exception('This UI does not implement any methods') 260 | 261 | def _nvim_notification(self, method, args): 262 | if method == 'redraw': 263 | self._nvim_updates.append(args) 264 | self._root.event_generate('<>', when='tail') 265 | 266 | 267 | def _is_invalid_key(c): 268 | try: 269 | return len(c.decode('utf-8')) != 1 or ord(c[0]) < 0x20 270 | except UnicodeDecodeError: 271 | return True 272 | 273 | 274 | nvim = attach('child', argv=['../neovim/build/bin/nvim', '--embed']) 275 | ui = NvimTk(nvim) 276 | 277 | # pr = cProfile.Profile() 278 | # pr.enable() 279 | ui.run() 280 | # pr.disable() 281 | # s = StringIO.StringIO() 282 | # ps = pstats.Stats(pr, stream=s) 283 | # ps.strip_dirs().sort_stats('ncalls').print_stats(15) 284 | # print s.getvalue() 285 | -------------------------------------------------------------------------------- /pytknvim/test_canvas.py: -------------------------------------------------------------------------------- 1 | 2 | from tkinter import * 3 | 4 | r = Tk() 5 | c = Canvas(r) 6 | 7 | c.pack() 8 | 9 | _colsize = 20 10 | _rowsize = 20 11 | 12 | top = 0 13 | bot = 10 14 | left = 0 15 | right = 10 16 | c.configure(xscrollincrement=_rowsize) 17 | colours = ['#000000', '#110000', '#220000', '#330000', '#440000', 18 | '#550000', '#660000', '#770000', '#880000', '#990000', 19 | '#AA0000'] 20 | 21 | cells= [[None for c in range(right+1)] for r in range(bot+1)] 22 | 23 | for rownum in range(bot, top - 1, -1): 24 | for colnum in range(right, left - 1, -1): 25 | x1 = colnum * _colsize 26 | y1 = rownum * _rowsize 27 | x2 = (colnum + 1) * _colsize 28 | y2 = (rownum + 1) * _rowsize 29 | # for each cell, create two items: The rectangle is used for 30 | # filling background and the text is for cell contents. 31 | rect = c.create_rectangle(x1, y1, x2, y2, width=0, fill=colours[rownum]) 32 | text = c.create_text(x1, y1, anchor='nw', width=1, text=' ') 33 | cells[rownum][colnum]=rect 34 | 35 | rgn = c.bbox('all') 36 | assert rgn 37 | 38 | rect1 = cells[3][0] 39 | rect2 = cells[bot - 2][right] 40 | x1, y1, *_ = c.bbox(rect1) 41 | x2, y2, *_ = c.bbox(rect2) 42 | rgn = (x1, y1, x2, y2) 43 | # rect = self.canvas.create_rectangle(x1, y1, x2, y2, width=0, fill='blue') 44 | 45 | # c.configure(scrollregion=rgn) 46 | # rect = c.create_rectangle(rgn, width=0, fill='blue') 47 | 48 | 49 | def scrollset(): 50 | c.configure(confine=False) 51 | c.configure(scrollregion=rgn) 52 | # c.create_rectangle(rgn, width=0, fill='blue') 53 | # c.yview_scroll(1, 'units') 54 | def scroll(): 55 | c.yview_scroll(1, 'units') 56 | def scroll_up(): 57 | c.yview_scroll(-1, 'units') 58 | 59 | scrollset = Button(r, text='scroll region set', command=scrollset) 60 | scrollset.pack() 61 | scroll = Button(r, text='scroll down', command=scroll) 62 | scroll.pack() 63 | scroll_up = Button(r, text='scroll up', command=scroll_up) 64 | scroll_up.pack() 65 | 66 | r.mainloop() 67 | -------------------------------------------------------------------------------- /pytknvim/test_screen.py: -------------------------------------------------------------------------------- 1 | 2 | import pytest 3 | 4 | from pytknvim.screen import DirtyScreen 5 | from pytknvim.screen import Screen 6 | 7 | 8 | dscreen = DirtyScreen() 9 | 10 | def assrt(screen, *values): 11 | assert list(dscreen.get()) == [*values] 12 | 13 | def test_simple(): 14 | dscreen.reset() 15 | dscreen.changed(1, 1, 1, 2) 16 | assrt(dscreen, (1, 1, 1, 2)) 17 | 18 | def test_second_range_added_after(): 19 | dscreen.reset() 20 | dscreen.changed(1, 1, 1, 2) 21 | dscreen.changed(1, 3, 1, 5) 22 | assrt(dscreen, (1, 1, 1, 2), (1, 3, 1, 5)) 23 | 24 | def test_second_range_added_touching_previous(): 25 | dscreen.reset() 26 | dscreen.changed(1, 1, 1, 2) 27 | dscreen.changed(1, 2, 1, 5) 28 | assrt(dscreen, (1, 1, 1, 5)) 29 | 30 | def test_second_range_added_before(): 31 | dscreen.reset() 32 | dscreen.changed(1, 5, 1, 6) 33 | dscreen.changed(1, 2, 1, 3) 34 | assrt(dscreen, (1, 5, 1, 6), (1, 2, 1, 3)) 35 | 36 | 37 | # screen = Screen() 38 | # def test_iter_works(): 39 | -------------------------------------------------------------------------------- /pytknvim/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timeyyy/pytknvim/d5fae053c3d39ded52afeb76b4d0d4d0e28ed20c/pytknvim/tests/__init__.py -------------------------------------------------------------------------------- /pytknvim/tests/test_file: -------------------------------------------------------------------------------- 1 | """Weak reference support for Python. 2 | 3 | This module is an implementation of PEP 205: 4 | 5 | http://www.python.org/dev/peps/pep-0205/ 6 | """ 7 | 8 | # Naming convention: Variables named "wr" are weak reference objects; 9 | # they are called this instead of "ref" to avoid name collisions with 10 | # the module-global ref() function imported from _weakref. 11 | 12 | import UserDict 13 | 14 | from _weakref import ( 15 | getweakrefcount, 16 | getweakrefs, 17 | ref, 18 | proxy, 19 | CallableProxyType, 20 | ProxyType, 21 | ReferenceType) 22 | 23 | from _weakrefset import WeakSet, _IterationGuard 24 | 25 | from exceptions import ReferenceError 26 | 27 | 28 | ProxyTypes = (ProxyType, CallableProxyType) 29 | 30 | __all__ = ["ref", "proxy", "getweakrefcount", "getweakrefs", 31 | "WeakKeyDictionary", "ReferenceError", "ReferenceType", "ProxyType", 32 | "CallableProxyType", "ProxyTypes", "WeakValueDictionary", 'WeakSet'] 33 | 34 | 35 | class WeakValueDictionary(UserDict.UserDict): 36 | """Mapping class that references values weakly. 37 | 38 | Entries in the dictionary will be discarded when no strong 39 | reference to the value exists anymore 40 | """ 41 | # We inherit the constructor without worrying about the input 42 | # dictionary; since it uses our .update() method, we get the right 43 | # checks (if the other dictionary is a WeakValueDictionary, 44 | # objects are unwrapped on the way out, and we always wrap on the 45 | # way in). 46 | 47 | def __init__(self, *args, **kw): 48 | def remove(wr, selfref=ref(self)): 49 | self = selfref() 50 | if self is not None: 51 | if self._iterating: 52 | self._pending_removals.append(wr.key) 53 | else: 54 | del self.data[wr.key] 55 | self._remove = remove 56 | # A list of keys to be removed 57 | self._pending_removals = [] 58 | self._iterating = set() 59 | UserDict.UserDict.__init__(self, *args, **kw) 60 | 61 | def _commit_removals(self): 62 | l = self._pending_removals 63 | d = self.data 64 | # We shouldn't encounter any KeyError, because this method should 65 | # always be called *before* mutating the dict. 66 | while l: 67 | del d[l.pop()] 68 | 69 | def __getitem__(self, key): 70 | o = self.data[key]() 71 | if o is None: 72 | raise KeyError, key 73 | else: 74 | return o 75 | 76 | def __delitem__(self, key): 77 | if self._pending_removals: 78 | self._commit_removals() 79 | del self.data[key] 80 | 81 | def __contains__(self, key): 82 | try: 83 | o = self.data[key]() 84 | except KeyError: 85 | return False 86 | return o is not None 87 | 88 | def has_key(self, key): 89 | try: 90 | o = self.data[key]() 91 | except KeyError: 92 | return False 93 | return o is not None 94 | 95 | def __repr__(self): 96 | return "" % id(self) 97 | 98 | def __setitem__(self, key, value): 99 | if self._pending_removals: 100 | self._commit_removals() 101 | self.data[key] = KeyedRef(value, self._remove, key) 102 | 103 | def clear(self): 104 | if self._pending_removals: 105 | self._commit_removals() 106 | self.data.clear() 107 | 108 | def copy(self): 109 | new = WeakValueDictionary() 110 | for key, wr in self.data.items(): 111 | o = wr() 112 | if o is not None: 113 | new[key] = o 114 | return new 115 | 116 | __copy__ = copy 117 | 118 | def __deepcopy__(self, memo): 119 | from copy import deepcopy 120 | new = self.__class__() 121 | for key, wr in self.data.items(): 122 | o = wr() 123 | if o is not None: 124 | new[deepcopy(key, memo)] = o 125 | return new 126 | 127 | def get(self, key, default=None): 128 | try: 129 | wr = self.data[key] 130 | except KeyError: 131 | return default 132 | else: 133 | o = wr() 134 | if o is None: 135 | # This should only happen 136 | return default 137 | else: 138 | return o 139 | 140 | def items(self): 141 | L = [] 142 | for key, wr in self.data.items(): 143 | o = wr() 144 | if o is not None: 145 | L.append((key, o)) 146 | return L 147 | 148 | def iteritems(self): 149 | with _IterationGuard(self): 150 | for wr in self.data.itervalues(): 151 | value = wr() 152 | if value is not None: 153 | yield wr.key, value 154 | 155 | def iterkeys(self): 156 | with _IterationGuard(self): 157 | for k in self.data.iterkeys(): 158 | yield k 159 | 160 | __iter__ = iterkeys 161 | 162 | def itervaluerefs(self): 163 | """Return an iterator that yields the weak references to the values. 164 | 165 | The references are not guaranteed to be 'live' at the time 166 | they are used, so the result of calling the references needs 167 | to be checked before being used. This can be used to avoid 168 | creating references that will cause the garbage collector to 169 | keep the values around longer than needed. 170 | 171 | """ 172 | with _IterationGuard(self): 173 | for wr in self.data.itervalues(): 174 | yield wr 175 | 176 | def itervalues(self): 177 | with _IterationGuard(self): 178 | for wr in self.data.itervalues(): 179 | obj = wr() 180 | if obj is not None: 181 | yield obj 182 | 183 | def popitem(self): 184 | if self._pending_removals: 185 | self._commit_removals() 186 | while 1: 187 | key, wr = self.data.popitem() 188 | o = wr() 189 | if o is not None: 190 | return key, o 191 | 192 | def pop(self, key, *args): 193 | if self._pending_removals: 194 | self._commit_removals() 195 | try: 196 | o = self.data.pop(key)() 197 | except KeyError: 198 | if args: 199 | return args[0] 200 | raise 201 | if o is None: 202 | raise KeyError, key 203 | else: 204 | return o 205 | 206 | def setdefault(self, key, default=None): 207 | try: 208 | wr = self.data[key] 209 | except KeyError: 210 | if self._pending_removals: 211 | self._commit_removals() 212 | self.data[key] = KeyedRef(default, self._remove, key) 213 | return default 214 | else: 215 | return wr() 216 | 217 | def update(self, dict=None, **kwargs): 218 | if self._pending_removals: 219 | self._commit_removals() 220 | d = self.data 221 | if dict is not None: 222 | if not hasattr(dict, "items"): 223 | dict = type({})(dict) 224 | for key, o in dict.items(): 225 | d[key] = KeyedRef(o, self._remove, key) 226 | if len(kwargs): 227 | self.update(kwargs) 228 | 229 | def valuerefs(self): 230 | """Return a list of weak references to the values. 231 | 232 | The references are not guaranteed to be 'live' at the time 233 | they are used, so the result of calling the references needs 234 | to be checked before being used. This can be used to avoid 235 | creating references that will cause the garbage collector to 236 | keep the values around longer than needed. 237 | 238 | """ 239 | return self.data.values() 240 | 241 | def values(self): 242 | L = [] 243 | for wr in self.data.values(): 244 | o = wr() 245 | if o is not None: 246 | L.append(o) 247 | return L 248 | 249 | 250 | class KeyedRef(ref): 251 | """Specialized reference that includes a key corresponding to the value. 252 | 253 | This is used in the WeakValueDictionary to avoid having to create 254 | a function object for each key stored in the mapping. A shared 255 | callback object can use the 'key' attribute of a KeyedRef instead 256 | of getting a reference to the key from an enclosing scope. 257 | 258 | """ 259 | 260 | __slots__ = "key", 261 | 262 | def __new__(type, ob, callback, key): 263 | self = ref.__new__(type, ob, callback) 264 | self.key = key 265 | return self 266 | 267 | def __init__(self, ob, callback, key): 268 | super(KeyedRef, self).__init__(ob, callback) 269 | 270 | 271 | class WeakKeyDictionary(UserDict.UserDict): 272 | """ Mapping class that references keys weakly. 273 | 274 | Entries in the dictionary will be discarded when there is no 275 | longer a strong reference to the key. This can be used to 276 | associate additional data with an object owned by other parts of 277 | an application without adding attributes to those objects. This 278 | can be especially useful with objects that override attribute 279 | accesses. 280 | """ 281 | 282 | def __init__(self, dict=None): 283 | self.data = {} 284 | def remove(k, selfref=ref(self)): 285 | self = selfref() 286 | if self is not None: 287 | if self._iterating: 288 | self._pending_removals.append(k) 289 | else: 290 | del self.data[k] 291 | self._remove = remove 292 | # A list of dead weakrefs (keys to be removed) 293 | self._pending_removals = [] 294 | self._iterating = set() 295 | if dict is not None: 296 | self.update(dict) 297 | 298 | def _commit_removals(self): 299 | # NOTE: We don't need to call this method before mutating the dict, 300 | # because a dead weakref never compares equal to a live weakref, 301 | # even if they happened to refer to equal objects. 302 | # However, it means keys may already have been removed. 303 | l = self._pending_removals 304 | d = self.data 305 | while l: 306 | try: 307 | del d[l.pop()] 308 | except KeyError: 309 | pass 310 | 311 | def __delitem__(self, key): 312 | del self.data[ref(key)] 313 | 314 | def __getitem__(self, key): 315 | return self.data[ref(key)] 316 | 317 | def __repr__(self): 318 | return "" % id(self) 319 | 320 | def __setitem__(self, key, value): 321 | self.data[ref(key, self._remove)] = value 322 | 323 | def copy(self): 324 | new = WeakKeyDictionary() 325 | for key, value in self.data.items(): 326 | o = key() 327 | if o is not None: 328 | new[o] = value 329 | return new 330 | 331 | __copy__ = copy 332 | 333 | def __deepcopy__(self, memo): 334 | from copy import deepcopy 335 | new = self.__class__() 336 | for key, value in self.data.items(): 337 | o = key() 338 | if o is not None: 339 | new[o] = deepcopy(value, memo) 340 | return new 341 | 342 | def get(self, key, default=None): 343 | return self.data.get(ref(key),default) 344 | 345 | def has_key(self, key): 346 | try: 347 | wr = ref(key) 348 | except TypeError: 349 | return 0 350 | return wr in self.data 351 | 352 | def __contains__(self, key): 353 | try: 354 | wr = ref(key) 355 | except TypeError: 356 | return 0 357 | return wr in self.data 358 | 359 | def items(self): 360 | L = [] 361 | for key, value in self.data.items(): 362 | o = key() 363 | if o is not None: 364 | L.append((o, value)) 365 | return L 366 | 367 | def iteritems(self): 368 | with _IterationGuard(self): 369 | for wr, value in self.data.iteritems(): 370 | key = wr() 371 | if key is not None: 372 | yield key, value 373 | 374 | def iterkeyrefs(self): 375 | """Return an iterator that yields the weak references to the keys. 376 | 377 | The references are not guaranteed to be 'live' at the time 378 | they are used, so the result of calling the references needs 379 | to be checked before being used. This can be used to avoid 380 | creating references that will cause the garbage collector to 381 | keep the keys around longer than needed. 382 | 383 | """ 384 | with _IterationGuard(self): 385 | for wr in self.data.iterkeys(): 386 | yield wr 387 | 388 | def iterkeys(self): 389 | with _IterationGuard(self): 390 | for wr in self.data.iterkeys(): 391 | obj = wr() 392 | if obj is not None: 393 | yield obj 394 | 395 | __iter__ = iterkeys 396 | 397 | def itervalues(self): 398 | with _IterationGuard(self): 399 | for value in self.data.itervalues(): 400 | yield value 401 | 402 | def keyrefs(self): 403 | """Return a list of weak references to the keys. 404 | 405 | The references are not guaranteed to be 'live' at the time 406 | they are used, so the result of calling the references needs 407 | to be checked before being used. This can be used to avoid 408 | creating references that will cause the garbage collector to 409 | keep the keys around longer than needed. 410 | 411 | """ 412 | return self.data.keys() 413 | 414 | def keys(self): 415 | L = [] 416 | for wr in self.data.keys(): 417 | o = wr() 418 | if o is not None: 419 | L.append(o) 420 | return L 421 | 422 | def popitem(self): 423 | while 1: 424 | key, value = self.data.popitem() 425 | o = key() 426 | if o is not None: 427 | return o, value 428 | 429 | def pop(self, key, *args): 430 | return self.data.pop(ref(key), *args) 431 | 432 | def setdefault(self, key, default=None): 433 | return self.data.setdefault(ref(key, self._remove),default) 434 | 435 | def update(self, dict=None, **kwargs): 436 | d = self.data 437 | if dict is not None: 438 | if not hasattr(dict, "items"): 439 | dict = type({})(dict) 440 | for key, value in dict.items(): 441 | d[ref(key, self._remove)] = value 442 | if len(kwargs): 443 | self.update(kwargs) 444 | -------------------------------------------------------------------------------- /pytknvim/tests/test_integration.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Neovim ui api is likely to change, 3 | also i do not understand really how and what it tries to do, 4 | it feels very granular and clunky. 5 | Makes it hard to do unit testing. 6 | Focuising on Integration testing... 7 | ''' 8 | 9 | import os 10 | import time 11 | import _thread as thread 12 | import queue 13 | from itertools import count 14 | import tkinter as tk 15 | 16 | import pytest 17 | 18 | from pytknvim.tk_ui import NvimTk 19 | from pytknvim.util import rand_str, attach_headless 20 | from pytknvim.tests.util import compare_screens, send_tk_key 21 | from pytknvim.tests.util import STATUS_BAR_HEIGHT, MAX_SCROLL, BUFFER_NUM 22 | 23 | 24 | def threaded_nvimtk(address, out_queue): 25 | root = tk.Tk() 26 | text = NvimTk(root) 27 | text.nvim_connect('-u', 'NONE', headless=True, address=address) 28 | text.pack() 29 | 30 | out_queue.put(text) 31 | 32 | def quit(): 33 | root.after_idle(root.quit) 34 | text.quit = quit 35 | root.mainloop() 36 | 37 | def thread_ui(): 38 | '''starts our us threaded so we can run tests 39 | this only works if we create the root in the same thread we 40 | call mainloop frome''' 41 | # scroll test will always check scrolling 42 | named_pipe = '/tmp/nvim{0}'.format(rand_str(16)) 43 | out_queue = queue.Queue() 44 | thread.start_new_thread(threaded_nvimtk, (named_pipe, out_queue)) 45 | nvimtk = out_queue.get(block=True) 46 | time.sleep(1) 47 | test_nvim = attach_headless(path=named_pipe) 48 | time.sleep(1) 49 | return nvimtk, test_nvim 50 | 51 | 52 | class VimCommands(): 53 | ''' 54 | Just for readablility 55 | ''' 56 | def v_insert_mode(self): 57 | self.send_tk_key('i') 58 | 59 | def v_normal_mode(self): 60 | self.send_tk_key('Esc') 61 | 62 | def v_back(self): 63 | self.v_normal_mode() 64 | self.send_tk_key('b') 65 | 66 | def v_delete_line(self): 67 | self.v_normal_mode() 68 | self.send_tk_key('d') 69 | self.send_tk_key('d') 70 | 71 | def v_up(self): 72 | self.v_normal_mode() 73 | self.send_tk_key('k') 74 | 75 | def v_down(self): 76 | self.v_normal_mode() 77 | self.send_tk_key('j') 78 | 79 | def v_undo(self): 80 | self.v_normal_mode() 81 | self.send_tk_key('u') 82 | 83 | def v_page_down(self): 84 | self.v_normal_mode() 85 | self.send_tk_key('G') 86 | 87 | def v_page_up(self): 88 | self.v_normal_mode() 89 | self.send_tk_key('g') 90 | self.send_tk_key('g') 91 | 92 | def v_jump(self, pos): 93 | self.v_normal_mode() 94 | self.send_tk_key(*"{}G".format(pos)) 95 | self.send_tk_key('Enter') 96 | 97 | 98 | class TestIntegration(VimCommands): 99 | 100 | def setup_class(cls): 101 | cls.nvimtk, cls.test_nvim = thread_ui() 102 | cls.nvimtk.test_nvim = cls.test_nvim 103 | cls.nvimtk._screen = cls.nvimtk.nvim_handler._screen 104 | cls.nvimtk.text = cls.nvimtk 105 | 106 | cls.nvimtk.max_scroll = MAX_SCROLL 107 | # This one has to be used because of threads and locks 108 | cls.nvim = cls.nvimtk.test_nvim 109 | # TODO Our compare_screen function doesn't work with number set 110 | cls.test_nvim.command("set nonumber") 111 | cls.test_nvim.command("set norelativenumber") 112 | cls.test_nvim.command("set noswapfile") 113 | 114 | def teardown_class(cls): 115 | cls.nvimtk.quit() 116 | time.sleep(0.2) 117 | 118 | def teardown_method(self, method): 119 | '''delete everything so we get a clean slate''' 120 | self.send_tk_key('Esc') 121 | buf = self.nvimtk.test_nvim.buffers[BUFFER_NUM] 122 | buf[:] = [""] 123 | 124 | 125 | def send_tk_key(self, *keys, modifyers=None): 126 | for key in keys: 127 | mod = None 128 | if type(key) in (tuple, list): 129 | key, mod = key 130 | send_tk_key(self.nvimtk, key, mod) 131 | 132 | def compare_screens(self): 133 | compare_screens(self.nvimtk) 134 | 135 | @pytest.mark.simple 136 | def test_load(self): 137 | self.compare_screens() 138 | 139 | @pytest.mark.simple 140 | def test_basic_insert(self): 141 | self.v_insert_mode() 142 | self.compare_screens() 143 | self.send_tk_key('a') 144 | self.compare_screens() 145 | self.send_tk_key('b', 'c', 'd', 'e') 146 | self.compare_screens() 147 | 148 | 149 | @pytest.mark.simple 150 | def test_enter_key(self): 151 | self.v_insert_mode() 152 | self.send_tk_key('b', 'c', 'd', 'e') 153 | self.send_tk_key('Enter') 154 | self.send_tk_key('Enter') 155 | self.compare_screens() 156 | self.send_tk_key('f', 'g', 'h') 157 | self.compare_screens() 158 | self.v_back() 159 | self.v_insert_mode() 160 | self.send_tk_key('1','2','3') 161 | self.send_tk_key('Enter') 162 | self.compare_screens() 163 | 164 | 165 | @pytest.mark.simple 166 | def test_delete_line(self): 167 | self.v_insert_mode() 168 | self.send_tk_key('o', 'n', 'e') 169 | self.v_delete_line() 170 | self.compare_screens() 171 | self.v_insert_mode() 172 | self.send_tk_key('o', 'n', 'e') 173 | self.send_tk_key('Enter') 174 | self.send_tk_key('t', 'w', 'o') 175 | self.send_tk_key('Enter') 176 | self.send_tk_key('t', 'h', 'r', 'e', 'e') 177 | self.compare_screens() 178 | self.v_delete_line() 179 | self.compare_screens() 180 | self.v_undo() 181 | self.compare_screens() 182 | 183 | 184 | def test_o_and_shift_o(self): 185 | self.v_insert_mode() 186 | self.send_tk_key('1') 187 | self.v_normal_mode() 188 | # Open onto new line 189 | self.send_tk_key('o') 190 | self.send_tk_key('3') 191 | self.compare_screens() 192 | self.v_normal_mode() 193 | # Open onto previous line 194 | self.send_tk_key('O') 195 | self.send_tk_key('2') 196 | self.compare_screens() 197 | # The end state of this tests should be 198 | # 1 199 | # 2 200 | # 201 | # 3 202 | 203 | 204 | # @nvimtester.register_test 205 | def test_scroll(self): 206 | # Force a scroll of an amount then compare_screens 207 | def _do(to_top): 208 | self.compare_screens() 209 | for i in range(0, to_top): 210 | self.v_up() 211 | self.compare_screens() 212 | for i in range(0, to_top): 213 | self.v_down() 214 | self.compare_screens() 215 | 216 | # TODO GET THIS DYNAMICALLY 217 | for i in count(1): 218 | self.v_page_down() 219 | self.v_insert_mode() 220 | scrolled = i\ 221 | - self.nvimtk.nvim_handler.current_rows\ 222 | + STATUS_BAR_HEIGHT 223 | self.send_tk_key(*str(i-1)) 224 | self.send_tk_key('Enter') 225 | 226 | self.nvimtk.max_scroll = 50 227 | if scrolled in (1, 2, self.nvimtk.max_scroll): 228 | to_top = self.nvimtk.nvim_handler.current_rows + scrolled 229 | _do(to_top) 230 | if scrolled == self.nvimtk.max_scroll: 231 | break 232 | 233 | 234 | def test_page_up_down(self): 235 | def _do(to_top): 236 | self.compare_screens() 237 | self.v_page_up() 238 | self.compare_screens() 239 | self.v_page_down() 240 | self.compare_screens() 241 | 242 | for i in count(1): 243 | self.v_page_down() 244 | self.v_insert_mode() 245 | scrolled = i\ 246 | - self.nvimtk.nvim_handler.current_rows\ 247 | + STATUS_BAR_HEIGHT 248 | self.send_tk_key(*str(i-1)) 249 | self.send_tk_key('Enter') 250 | 251 | # Make sure our last jump covers an entire page 252 | last_scroll = old_scroll = self.nvimtk.max_scroll 253 | if last_scroll < self.nvimtk.nvim_handler.current_rows: 254 | last_scroll = self.nvimtk.nvim_handler.current_rows + 1 255 | self.nvimtk.max_scroll = last_scroll 256 | 257 | if scrolled in (1, 2, last_scroll): 258 | to_top = self.nvimtk.nvim_handler.current_rows + scrolled 259 | time.sleep(1) 260 | _do(to_top) 261 | if scrolled == last_scroll: 262 | self.nvimtk.max_scroll = old_scroll 263 | break 264 | 265 | 266 | def test_big_file(self): 267 | ''' 268 | doesn't test anything that our other tests doesn't, 269 | but just paves the way for testing a file 270 | ''' 271 | test_file = os.path.join(os.path.dirname(__file__), 272 | 'test_file') 273 | self.v_normal_mode() 274 | self.send_tk_key(*':e! '+test_file) 275 | self.send_tk_key('Enter') 276 | # Give time for actions to take place 277 | time.sleep(1) 278 | self.compare_screens() 279 | # TODO READ LEN OF FILE 280 | old_max = self.nvimtk.max_scroll 281 | self.nvimtk.max_scroll = 500 282 | self.v_page_down() 283 | time.sleep(0.5) 284 | self.compare_screens() 285 | self.v_page_up() 286 | time.sleep(0.5) 287 | self.compare_screens() 288 | self.nvimtk.max_sroll = old_max 289 | self.nvim.command('bwipeout') 290 | 291 | 292 | def test_number(self): 293 | to_test = ('load', 'basic_insert', 'enter_key') 294 | self.nvim.command("set nonumber") 295 | self.nvim.command("set relativenumber") 296 | time.sleep(0.5) 297 | for test in to_test: 298 | method = getattr(self, 'test_' + test) 299 | method() 300 | self.teardown_method(method) 301 | -------------------------------------------------------------------------------- /pytknvim/tests/util.py: -------------------------------------------------------------------------------- 1 | from itertools import count 2 | import time 3 | 4 | from pytknvim.tk_ui import KEY_TABLE, _stringify_key 5 | 6 | 7 | MAX_SCROLL = 10 8 | BUFFER_NUM = 1 9 | # TODO GET THIS DYNAMICALLY 10 | STATUS_BAR_HEIGHT = 3 11 | 12 | 13 | class Unnest(Exception): 14 | '''Used to exit a nested loop''' 15 | pass 16 | 17 | 18 | def _textwidget_rows(widget): 19 | '''Return all tkinter chars as rows''' 20 | # Rows start counting at 1 in tkinter text widget 21 | end_row, end_col = (int(i) for i in 22 | widget.index('end-1c').split('.')) 23 | try: 24 | for row in count(1): 25 | line = [] 26 | for col in count(0): 27 | # Exit out 28 | if end_row == row: 29 | if end_col == col: 30 | raise Unnest 31 | # Add if not on new line 32 | char = widget.get('{0}.{1}'.format(row,col)) 33 | line.append(char) 34 | if char == '\n': 35 | yield ''.join(i for i in line) 36 | break 37 | except Unnest: 38 | pass 39 | 40 | 41 | def _nvim_rows(buff): 42 | '''get all neovim rows''' 43 | all_rows = [] 44 | for row in buff: 45 | all_rows.append(row) 46 | return all_rows 47 | 48 | 49 | def _screen_rows(cells): 50 | '''get all rows of the internal screen ''' 51 | for row in cells: 52 | line = [] 53 | for char in row: 54 | line.append(char.text) 55 | yield ''.join(i for i in line) 56 | 57 | 58 | def _remove_sidebar(line): 59 | '''remove the numbers and space if they are there''' 60 | found_numbers = False 61 | found_char = False 62 | for i, char in enumerate(line): 63 | if not found_numbers: 64 | try: 65 | int(char) 66 | except ValueError: 67 | if char != ' ': 68 | found_char = True 69 | continue 70 | else: 71 | found_numbers = True 72 | else: 73 | try: 74 | int(char) 75 | except ValueError: 76 | break 77 | else: 78 | continue 79 | if found_numbers: 80 | if not found_char: 81 | return line[i+1:] 82 | else: 83 | raise Exception('chars where found in sidebar...', 84 | line) 85 | else: 86 | return line 87 | 88 | 89 | def _parse(lines, line_length, mock_inst, eol_trim): 90 | ''' 91 | make the values for screen and tkinter text widget 92 | look like neovims values, 93 | neovim doesn't give us the ~ at the start, 94 | also remove our newline chars and end spacing 95 | also remove status bar stuff 96 | ''' 97 | # Unfortunatley the code is a bit confusing 98 | # I thought the handling was more similar than 99 | # different for the two cases... 100 | file_name = mock_inst.test_nvim.eval('expand("%:t")') 101 | side_bar = mock_inst.test_nvim.eval('&relativenumber') \ 102 | or mock_inst.test_nvim.eval('&number') 103 | all_rows = [] 104 | for i, line in enumerate(lines): 105 | # screen doesn't have a \n 106 | if eol_trim: 107 | assert line[-eol_trim:] == '\n' 108 | try: 109 | assert len(line)-eol_trim == line_length 110 | except AssertionError: 111 | # TODO does this line length need to match? 112 | if '-- INSERT --' not in line: 113 | raise 114 | break 115 | if side_bar: 116 | line = _remove_sidebar(line) 117 | if line[0] == '~': 118 | if eol_trim: 119 | parsed = line[1:-eol_trim].rstrip() 120 | else: 121 | parsed = line[1:].rstrip() 122 | if not parsed: 123 | # do not add blank lists 124 | continue 125 | else: 126 | if eol_trim: 127 | parsed = line[:-eol_trim].rstrip() 128 | else: 129 | parsed = line.rstrip() 130 | if not parsed: 131 | parsed = '' 132 | all_rows.append(parsed) 133 | 134 | # Remove the status bar (screen has a new line padded after) 135 | for i in range(1, 3): 136 | if '[No Name]' in all_rows[-i]: 137 | del all_rows[-i:] 138 | break 139 | elif file_name and file_name in all_rows[-i]: 140 | del all_rows[-i-1:] 141 | break 142 | else: 143 | raise Exception("couldn't find status bar...") 144 | return all_rows 145 | 146 | 147 | def _parse_text(lines, line_length, mock_inst): 148 | return _parse(lines, line_length, mock_inst, eol_trim=1) 149 | 150 | 151 | def _parse_screen(lines, line_length, mock_inst): 152 | return _parse(lines, line_length, mock_inst ,eol_trim=0) 153 | 154 | 155 | def _check_scrolled(screen, nvim_rows, parsed_thing, 156 | max_scroll): 157 | ''' 158 | the screen and text widget only ever store 159 | what is being showed to the user, 160 | while neovim gives the entire buffer. 161 | 162 | we try matching slices of the lists to check 163 | if it has been scrolled 164 | checks max 10 scrolls forward and 10 backward 165 | ''' 166 | viewable = len(parsed_thing) 167 | screen_size = screen.bot - screen.top 168 | if viewable != screen_size: 169 | maybe_status_bar = screen_size - viewable 170 | if maybe_status_bar > STATUS_BAR_HEIGHT: 171 | raise Exception('fix this...') 172 | for i in range(0, max_scroll + 1): 173 | end_index = i + viewable 174 | try: 175 | # CHECK IF SCROLLED DOWN 176 | assert nvim_rows[i:end_index] == parsed_thing 177 | except AssertionError: 178 | pass 179 | else: 180 | return True 181 | try: 182 | # CHECK IF SCROLLED UP 183 | assert nvim_rows[-end_index:-i] == parsed_thing 184 | except AssertionError: 185 | pass 186 | else: 187 | return True 188 | else: 189 | raise AssertionError('nvim rows did not match..\n \ 190 | make sure scroll amount is less than ' 191 | + str(max_scroll)) 192 | 193 | 194 | def compare_screens(mock_inst): 195 | ''' 196 | compares our text widget values with the nvim values. 197 | compares our internal screen with text widget 198 | 199 | nvim only makes the text (no spacing or newlines avaliable) 200 | 201 | ''' 202 | line_length = mock_inst._screen.columns 203 | 204 | nvim_rows = _nvim_rows(mock_inst.test_nvim.buffers[BUFFER_NUM]) 205 | text_rows = _textwidget_rows(mock_inst.text) 206 | screen_rows = _screen_rows(mock_inst._screen._cells) 207 | 208 | parsed_text = _parse_text(text_rows, line_length, mock_inst) 209 | parsed_screen = _parse_screen(screen_rows, line_length, 210 | mock_inst) 211 | try: 212 | assert len(parsed_screen) == len(parsed_text) 213 | except: 214 | import pdb;pdb.set_trace() 215 | try: 216 | assert len(nvim_rows) == len(parsed_screen) 217 | # A scroll may have happend 218 | except AssertionError: 219 | _check_scrolled(mock_inst._screen, 220 | nvim_rows, 221 | parsed_text, 222 | mock_inst.max_scroll) 223 | # TODO move this inside _check_scrolled 224 | # can use the offset to check all cells 225 | for sr, tr in zip(parsed_screen, parsed_text): 226 | assert sr == tr 227 | else: 228 | # No Scroll happened they should all match 229 | for sr, tr, nr in zip(parsed_screen, 230 | parsed_text, nvim_rows): 231 | assert sr == tr == nr 232 | 233 | 234 | class Event(): 235 | def __init__(self, key, modifyer=None): 236 | ''' 237 | mimics a tkinter key press event. 238 | this just fudges it enough so it passes the checks 239 | for our function... 240 | ''' 241 | self.keysym = key 242 | self.char = key 243 | self.state = 0 244 | self.keycode = ord(key) 245 | self.keysym_num= ord(key) 246 | if modifyer: 247 | self.state = 1337 248 | self.keysym = modifyer.capitalize() 249 | 250 | 251 | def send_tk_key(tknvim, key, modifyer=None): 252 | ''' 253 | send a key through to our class as a tkinter event 254 | passed as tkinter or vim keys i.e Esc 255 | pass in a modifyer as, shift, alt, or ctrl 256 | ''' 257 | assert modifyer in ('shift', 'alt', 'ctrl', None) 258 | if len(key) == 1: 259 | event = Event(key, modifyer) 260 | tknvim.nvim_handler.tk_key_pressed(event) 261 | else: 262 | # Special key 263 | for value in KEY_TABLE.values(): 264 | if value == key: 265 | break 266 | else: 267 | if key in KEY_TABLE: 268 | key = KEY_TABLE[key] 269 | else: 270 | raise KeyError( 271 | 'Please pass an acceptable key in') 272 | vimified = _stringify_key(key, []) 273 | tknvim.nvim_handler._bridge.input(vimified) 274 | time.sleep(0.02) 275 | -------------------------------------------------------------------------------- /pytknvim/tk_canvas.py: -------------------------------------------------------------------------------- 1 | """Neovim TKinter UI.""" 2 | # EXAMPLE FROM TATRRUIDA 3 | import sys 4 | from Tkinter import Canvas, Tk 5 | from collections import deque 6 | from threading import Thread 7 | # import StringIO, cProfile, pstats 8 | 9 | from neovim import attach 10 | 11 | from tkFont import Font 12 | 13 | SPECIAL_KEYS = { 14 | 'Escape': 'Esc', 15 | 'Return': 'CR', 16 | 'BackSpace': 'BS', 17 | 'Prior': 'PageUp', 18 | 'Next': 'PageDown', 19 | 'Delete': 'Del', 20 | } 21 | 22 | 23 | if sys.version_info < (3, 0): 24 | range = xrange 25 | 26 | 27 | class NvimTk(object): 28 | 29 | """Wraps all nvim/tk event handling.""" 30 | 31 | def __init__(self, nvim): 32 | """Initialize with a Nvim instance.""" 33 | self._nvim = nvim 34 | self._attrs = {} 35 | self._nvim_updates = deque() 36 | self._canvas = None 37 | self._fg = '#000000' 38 | self._bg = '#ffffff' 39 | 40 | def run(self): 41 | """Start the UI.""" 42 | self._tk_setup() 43 | t = Thread(target=self._nvim_event_loop) 44 | t.daemon = True 45 | t.start() 46 | self._root.mainloop() 47 | 48 | def _tk_setup(self): 49 | self._root = Tk() 50 | self._root.bind('<>', self._tk_nvim_redraw) 51 | self._root.bind('<>', self._tk_nvim_detach) 52 | self._root.bind('', self._tk_key) 53 | 54 | def _tk_nvim_redraw(self, *args): 55 | update = self._nvim_updates.popleft() 56 | for update in update: 57 | handler = getattr(self, '_tk_nvim_' + update[0]) 58 | for args in update[1:]: 59 | handler(*args) 60 | 61 | def _tk_nvim_detach(self, *args): 62 | self._root.destroy() 63 | 64 | def _tk_nvim_resize(self, width, height): 65 | self._tk_redraw_canvas(width, height) 66 | 67 | def _tk_nvim_clear(self): 68 | self._tk_clear_region(0, self._height - 1, 0, self._width - 1) 69 | 70 | def _tk_nvim_eol_clear(self): 71 | row, col = (self._cursor_row, self._cursor_col,) 72 | self._tk_clear_region(row, row, col, self._scroll_right) 73 | 74 | def _tk_nvim_cursor_goto(self, row, col): 75 | self._cursor_row = row 76 | self._cursor_col = col 77 | 78 | def _tk_nvim_cursor_on(self): 79 | pass 80 | 81 | def _tk_nvim_cursor_off(self): 82 | pass 83 | 84 | def _tk_nvim_mouse_on(self): 85 | pass 86 | 87 | def _tk_nvim_mouse_off(self): 88 | pass 89 | 90 | def _tk_nvim_insert_mode(self): 91 | pass 92 | 93 | def _tk_nvim_normal_mode(self): 94 | pass 95 | 96 | def _tk_nvim_set_scroll_region(self, top, bot, left, right): 97 | self._scroll_top = top 98 | self._scroll_bot = bot 99 | self._scroll_left = left 100 | self._scroll_right = right 101 | 102 | def _tk_nvim_scroll(self, count): 103 | top, bot = (self._scroll_top, self._scroll_bot,) 104 | left, right = (self._scroll_left, self._scroll_right,) 105 | 106 | if count > 0: 107 | destroy_top = top 108 | destroy_bot = top + count - 1 109 | move_top = destroy_bot + 1 110 | move_bot = bot 111 | fill_top = move_bot + 1 112 | fill_bot = fill_top + count - 1 113 | else: 114 | destroy_top = bot + count + 1 115 | destroy_bot = bot 116 | move_top = top 117 | move_bot = destroy_top - 1 118 | fill_bot = move_top - 1 119 | fill_top = fill_bot + count + 1 120 | 121 | # destroy items that would be moved outside the scroll region after 122 | # scrolling 123 | # self._tk_clear_region(destroy_top, destroy_bot, left, right) 124 | # self._tk_clear_region(move_top, move_bot, left, right) 125 | self._tk_destroy_region(destroy_top, destroy_bot, left, right) 126 | self._tk_tag_region('move', move_top, move_bot, left, right) 127 | self._canvas.move('move', 0, -count * self._rowsize) 128 | self._canvas.dtag('move', 'move') 129 | # self._tk_fill_region(fill_top, fill_bot, left, right) 130 | 131 | 132 | def _tk_nvim_highlight_set(self, attrs): 133 | self._attrs = attrs 134 | 135 | def _tk_nvim_put(self, data): 136 | # choose a Font instance 137 | font = self._fnormal 138 | if self._attrs.get('bold', False): 139 | font = self._fbold 140 | if self._attrs.get('italic', False): 141 | font = self._fbolditalic if font == self._fbold else self._fitalic 142 | # colors 143 | fg = "#{0:0{1}x}".format(self._attrs.get('foreground', self._fg), 6) 144 | bg = "#{0:0{1}x}".format(self._attrs.get('background', self._bg), 6) 145 | # get the "text" and "rect" which correspond to the current cell 146 | x, y = self._tk_get_coords(self._cursor_row, self._cursor_col) 147 | items = self._canvas.find_overlapping(x, y, x + 1, y + 1) 148 | if len(items) != 2: 149 | # caught part the double-width character in the cell to the left, 150 | # filter items which dont have the same horizontal coordinate as 151 | # "x" 152 | predicate = lambda item: self._canvas.coords(item)[0] == x 153 | items = filter(predicate, items) 154 | # rect has lower id than text, sort to unpack correctly 155 | rect, text = sorted(items) 156 | self._canvas.itemconfig(text, fill=fg, font=font, text=data or ' ') 157 | self._canvas.itemconfig(rect, fill=bg) 158 | self._tk_nvim_cursor_goto(self._cursor_row, self._cursor_col + 1) 159 | 160 | def _tk_nvim_bell(self): 161 | self._root.bell() 162 | 163 | def _tk_nvim_update_fg(self, fg): 164 | self._fg = "#{0:0{1}x}".format(fg, 6) 165 | 166 | def _tk_nvim_update_bg(self, bg): 167 | self._bg = "#{0:0{1}x}".format(bg, 6) 168 | 169 | def _tk_redraw_canvas(self, width, height): 170 | if self._canvas: 171 | self._canvas.destroy() 172 | self._fnormal = Font(family='Monospace', size=13) 173 | self._fbold = Font(family='Monospace', weight='bold', size=13) 174 | self._fitalic = Font(family='Monospace', slant='italic', size=13) 175 | self._fbolditalic = Font(family='Monospace', weight='bold', 176 | slant='italic', size=13) 177 | self._colsize = self._fnormal.measure('A') 178 | self._rowsize = self._fnormal.metrics('linespace') 179 | self._canvas = Canvas(self._root, width=self._colsize * width, 180 | height=self._rowsize * height) 181 | self._tk_fill_region(0, height - 1, 0, width - 1) 182 | self._cursor_row = 0 183 | self._cursor_col = 0 184 | self._scroll_top = 0 185 | self._scroll_bot = height - 1 186 | self._scroll_left = 0 187 | self._scroll_right = width - 1 188 | self._width, self._height = (width, height,) 189 | self._canvas.pack() 190 | 191 | def _tk_fill_region(self, top, bot, left, right): 192 | # create columns from right to left so the left columns have a 193 | # higher z-index than the right columns. This is required to 194 | # properly display characters that cross cell boundary 195 | for rownum in range(bot, top - 1, -1): 196 | for colnum in range(right, left - 1, -1): 197 | x1 = colnum * self._colsize 198 | y1 = rownum * self._rowsize 199 | x2 = (colnum + 1) * self._colsize 200 | y2 = (rownum + 1) * self._rowsize 201 | # for each cell, create two items: The rectangle is used for 202 | # filling background and the text is for cell contents. 203 | self._canvas.create_rectangle(x1, y1, x2, y2, 204 | fill=self._bg, width=0) 205 | self._canvas.create_text(x1, y1, anchor='nw', 206 | font=self._fnormal, width=1, 207 | fill=self._fg, text=' ') 208 | 209 | def _tk_clear_region(self, top, bot, left, right): 210 | self._tk_tag_region('clear', top, bot, left, right) 211 | self._canvas.itemconfig('clear', fill=self._bg) 212 | self._canvas.dtag('clear', 'clear') 213 | 214 | def _tk_destroy_region(self, top, bot, left, right): 215 | self._tk_tag_region('destroy', top, bot, left, right) 216 | self._canvas.delete('destroy') 217 | self._canvas.dtag('destroy', 'destroy') 218 | 219 | def _tk_tag_region(self, tag, top, bot, left, right): 220 | x1, y1 = self._tk_get_coords(top, left) 221 | x2, y2 = self._tk_get_coords(bot, right) 222 | self._canvas.addtag_overlapping(tag, x1, y1, x2 + 1, y2 + 1) 223 | 224 | def _tk_get_coords(self, row, col): 225 | x = col * self._colsize 226 | y = row * self._rowsize 227 | return x, y 228 | 229 | def _tk_key(self, event): 230 | if 0xffe1 <= event.keysym_num <= 0xffee: 231 | # this is a modifier key, ignore. Source: 232 | # https://www.tcl.tk/man/tcl8.4/TkCmd/keysyms.htm 233 | return 234 | # Translate to Nvim representation of keys 235 | send = [] 236 | if event.state & 0x1: 237 | send.append('S') 238 | if event.state & 0x4: 239 | send.append('C') 240 | if event.state & (0x8 | 0x80): 241 | send.append('A') 242 | special = len(send) > 0 243 | key = event.char 244 | if _is_invalid_key(key): 245 | special = True 246 | key = event.keysym 247 | send.append(SPECIAL_KEYS.get(key, key)) 248 | send = '-'.join(send) 249 | if special: 250 | send = '<' + send + '>' 251 | nvim = self._nvim 252 | nvim.session.threadsafe_call(lambda: nvim.input(send)) 253 | 254 | def _nvim_event_loop(self): 255 | self._nvim.session.run(self._nvim_request, 256 | self._nvim_notification, 257 | lambda: self._nvim.attach_ui(80, 24)) 258 | self._root.event_generate('<>', when='tail') 259 | 260 | def _nvim_request(self, method, args): 261 | raise Exception('This UI does not implement any methods') 262 | 263 | def _nvim_notification(self, method, args): 264 | if method == 'redraw': 265 | self._nvim_updates.append(args) 266 | self._root.event_generate('<>', when='tail') 267 | 268 | 269 | def _is_invalid_key(c): 270 | try: 271 | return len(c.decode('utf-8')) != 1 or ord(c[0]) < 0x20 272 | except UnicodeDecodeError: 273 | return True 274 | 275 | 276 | nvim = attach('child', argv=['../neovim/build/bin/nvim', '--embed']) 277 | ui = NvimTk(nvim) 278 | 279 | # pr = cProfile.Profile() 280 | # pr.enable() 281 | ui.run() 282 | # pr.disable() 283 | # s = StringIO.StringIO() 284 | # ps = pstats.Stats(pr, stream=s) 285 | # ps.strip_dirs().sort_stats('ncalls').print_stats(15) 286 | # print s.getvalue() 287 | -------------------------------------------------------------------------------- /pytknvim/tk_ui.py: -------------------------------------------------------------------------------- 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 neovim import attach 14 | 15 | # from tkquick.gui.tools import rate_limited 16 | 17 | from pytknvim.ui_bridge import UIBridge 18 | from pytknvim.screen import Screen 19 | from pytknvim.util import _stringify_key, _stringify_color 20 | from pytknvim.util import _split_color, _invert_color 21 | from pytknvim.util import debug_echo 22 | from pytknvim.util import attach_headless, attach_child 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('', 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('', 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=-1, debug_echo=False): 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, *nvim_args, address=None, headless=False, exec_name='nvim'): 262 | # Value has been set, otherwise default to this functions default value 263 | if self.address != -1 and not address: 264 | address = self.address 265 | 266 | if headless: 267 | nvim = attach_headless(nvim_args, address) 268 | elif address: 269 | nvim = attach('socket', path=address, argv=nvim_args) 270 | else: 271 | nvim = attach_child(nvim_args=nvim_args, exec_name=exec_name) 272 | 273 | self._bridge.connect(nvim, self.text) 274 | self._screen = Screen(self.current_cols, self.current_rows) 275 | self._bridge.attach(self.current_cols, self.current_rows, rgb=True) 276 | # if len(sys.argv) > 1: 277 | # nvim.command('edit ' + sys.argv[1]) 278 | self.connected = True 279 | self.text.nvim = nvim 280 | return nvim 281 | 282 | @debug_echo 283 | def _nvim_resize(self, cols, rows): 284 | '''Let neovim update tkinter when neovim changes size''' 285 | # TODO 286 | # Make sure it works when user changes font, 287 | # only can support mono font i think.. 288 | self._screen = Screen(cols, rows) 289 | 290 | @debug_echo 291 | def _nvim_clear(self): 292 | ''' 293 | wipe everyything, even the ~ and status bar 294 | ''' 295 | self._screen.clear() 296 | 297 | self.tk_delete_line(del_eol=True, 298 | screen_row=0, 299 | screen_col=0, 300 | count=self.current_rows) 301 | # Add spaces everywhere 302 | self.tk_pad_line(screen_row=0, 303 | screen_col=0, 304 | count=self.current_rows, 305 | add_eol=True,) 306 | 307 | 308 | @debug_echo 309 | def _nvim_eol_clear(self): 310 | ''' 311 | delete from index to end of line, 312 | fill with whitespace 313 | leave eol intact 314 | ''' 315 | self._screen.eol_clear() 316 | self.tk_delete_line(del_eol=False) 317 | self.tk_pad_line(screen_col=self._screen.col, 318 | add_eol=False) 319 | 320 | 321 | @debug_echo 322 | def _nvim_cursor_goto(self, row, col): 323 | '''Move gui cursor to position''' 324 | self._screen.cursor_goto(row, col) 325 | self.text.see("1.0") 326 | 327 | 328 | @debug_echo 329 | def _nvim_busy_start(self): 330 | self._busy = True 331 | 332 | 333 | @debug_echo 334 | def _nvim_busy_stop(self): 335 | self._busy = False 336 | 337 | 338 | @debug_echo 339 | def _nvim_mouse_on(self): 340 | self.mouse_enabled = True 341 | 342 | 343 | @debug_echo 344 | def _nvim_mouse_off(self): 345 | self.mouse_enabled = False 346 | 347 | 348 | @debug_echo 349 | def _nvim_mode_change(self, mode): 350 | self._insert_cursor = mode == 'insert' 351 | 352 | 353 | @debug_echo 354 | def _nvim_set_scroll_region(self, top, bot, left, right): 355 | self._screen.set_scroll_region(top, bot, left, right) 356 | 357 | 358 | @debug_echo 359 | def _nvim_scroll(self, count): 360 | self._flush() 361 | self._screen.scroll(count) 362 | abs_count = abs(count) 363 | # The minus 1 is because we want our tk_* functions 364 | # to operate on the row passed in 365 | delta = abs_count - 1 366 | # Down 367 | if count > 0: 368 | delete_row = self._screen.top 369 | pad_row = self._screen.bot - delta 370 | # Up 371 | else: 372 | delete_row = self._screen.bot - delta 373 | pad_row = self._screen.top 374 | 375 | self.tk_delete_line(screen_row=delete_row, 376 | screen_col=0, 377 | del_eol=True, 378 | count=abs_count) 379 | self.tk_pad_line(screen_row=pad_row, 380 | screen_col=0, 381 | add_eol=True, 382 | count=abs_count) 383 | # self.text.yview_scroll(count, 'units') 384 | 385 | 386 | # @debug_echo 387 | def _nvim_highlight_set(self, attrs): 388 | self._attrs = self._get_tk_attrs(attrs) 389 | 390 | 391 | # @debug_echo 392 | def _reset_attrs_cache(self): 393 | self._tk_text_cache = {} 394 | self._tk_attrs_cache = {} 395 | 396 | 397 | @debug_echo 398 | def _get_tk_attrs(self, attrs): 399 | key = tuple(sorted((k, v,) for k, v in (attrs or {}).items())) 400 | rv = self._tk_attrs_cache.get(key, None) 401 | if rv is None: 402 | fg = self._foreground if self._foreground != -1\ 403 | else 0 404 | bg = self._background if self._background != -1\ 405 | else 0xffffff 406 | n = {'foreground': _split_color(fg), 407 | 'background': _split_color(bg),} 408 | if attrs: 409 | # make sure that fg and bg are assigned first 410 | for k in ['foreground', 'background']: 411 | if k in attrs: 412 | n[k] = _split_color(attrs[k]) 413 | for k, v in attrs.items(): 414 | if k == 'reverse': 415 | n['foreground'], n['background'] = \ 416 | n['background'], n['foreground'] 417 | elif k == 'italic': 418 | n['slant'] = 'italic' 419 | elif k == 'bold': 420 | n['weight'] = 'bold' 421 | # TODO 422 | # if self._bold_spacing: 423 | # n['letter_spacing'] \ 424 | # = str(self._bold_spacing) 425 | elif k == 'underline': 426 | n['underline'] = '1' 427 | c = dict(n) 428 | c['foreground'] = _invert_color(*_split_color(fg)) 429 | c['background'] = _invert_color(*_split_color(bg)) 430 | c['foreground'] = _stringify_color(*c['foreground']) 431 | c['background'] = _stringify_color(*c['background']) 432 | n['foreground'] = _stringify_color(*n['foreground']) 433 | n['background'] = _stringify_color(*n['background']) 434 | # n = normal, c = cursor 435 | rv = (n, c) 436 | self._tk_attrs_cache[key] = (n, c) 437 | return rv 438 | 439 | 440 | # @debug_echo 441 | def _nvim_put(self, text): 442 | ''' 443 | put a charachter into position, we only write the lines 444 | when a new row is being edited 445 | ''' 446 | if self._screen.row != self._pending[0]: 447 | # write to screen if vim puts stuff on a new line 448 | self._flush() 449 | 450 | self._screen.put(text, self._attrs) 451 | self._pending[1] = min(self._screen.col - 1, 452 | self._pending[1]) 453 | self._pending[2] = max(self._screen.col, 454 | self._pending[2]) 455 | 456 | 457 | # @debug_echo 458 | def _nvim_bell(self): 459 | pass 460 | 461 | 462 | # @debug_echo 463 | def _nvim_visual_bell(self): 464 | pass 465 | 466 | 467 | # @debug_echo 468 | def _nvim_update_fg(self, fg): 469 | self._foreground = fg 470 | self._reset_attrs_cache() 471 | foreground = self._get_tk_attrs(None)[0]['foreground'] 472 | self.text.config(foreground=foreground) 473 | 474 | 475 | # @debug_echo 476 | def _nvim_update_bg(self, bg): 477 | self._background = bg 478 | self._reset_attrs_cache() 479 | background = self._get_tk_attrs(None)[0]['background'] 480 | self.text.config(background=background) 481 | 482 | 483 | # @debug_echo 484 | def _nvim_update_suspend(self, arg): 485 | self.root.iconify() 486 | 487 | 488 | # @debug_echo 489 | def _nvim_set_title(self, title): 490 | self.root.title(title) 491 | 492 | 493 | # @debug_echo 494 | def _nvim_set_icon(self, icon): 495 | self._icon = tk.PhotoImage(file=icon) 496 | self.root.tk.call('wm', 'iconphoto', 497 | self.root._w, self._icon) 498 | 499 | 500 | # @debug_echo 501 | def _flush(self): 502 | row, startcol, endcol = self._pending 503 | self._pending[0] = self._screen.row 504 | self._pending[1] = self._screen.col 505 | self._pending[2] = self._screen.col 506 | if startcol == endcol: 507 | #print('startcol is endcol return, row %s col %s'% (self._screen.row, self._screen.col)) 508 | return 509 | ccol = startcol 510 | buf = [] 511 | bold = False 512 | for _, col, text, attrs in self._screen.iter(row, 513 | row, startcol, endcol - 1): 514 | newbold = attrs and 'bold' in attrs[0] 515 | if newbold != bold or not text: 516 | if buf: 517 | self._draw(row, ccol, buf) 518 | bold = newbold 519 | buf = [(text, attrs,)] 520 | ccol = col 521 | else: 522 | buf.append((text, attrs,)) 523 | if buf: 524 | self._draw(row, ccol, buf) 525 | else: 526 | pass 527 | # print('flush with no draw') 528 | 529 | 530 | @debug_echo 531 | def _draw(self, row, col, data): 532 | ''' 533 | updates a line :) 534 | ''' 535 | for text, attrs in data: 536 | try: 537 | start = end 538 | except UnboundLocalError: 539 | start = "{}.{}".format(row + 1, col) 540 | end = start+'+{0}c'.format(len(text)) 541 | 542 | if not attrs: 543 | attrs = self._get_tk_attrs(None) 544 | attrs = attrs[0] 545 | 546 | # if self.debug_echo: 547 | # print('replacing ', repr(self.text.get(start, end))) 548 | # print('with ', repr(text), ' at ', start, ' ',end) 549 | self.text.replace(start, end, text) 550 | 551 | if attrs: 552 | self.text.apply_attribute(attrs, start, end) 553 | start 554 | 555 | 556 | @debug_echo 557 | def _nvim_exit(self, arg): 558 | print('in exit') 559 | import pdb;pdb.set_trace() 560 | # self.root.destroy() 561 | 562 | @debug_echo 563 | def _nvim_update_sp(self, *args): 564 | pass 565 | 566 | 567 | class NvimTk(tk_util.Text): 568 | '''namespace for neovim related methods, 569 | requests are generally prefixed with _tk_, 570 | responses are prefixed with _nvim_ 571 | ''' 572 | # we get keys, mouse movements inside tkinter, using binds, 573 | # These binds are handed off to neovim using _input 574 | 575 | # Neovim interpruts the actions and calls certain 576 | # functions which are defined and implemented in tk 577 | 578 | # The api from neovim does stuff line by line, 579 | # so each callback from neovim produces a series 580 | # of miniscule actions which in the end updates a line 581 | 582 | # So we can shutdown the neovim connections 583 | instances = [] 584 | 585 | def __init__(self, parent, *_, address=False, toplevel=False, **kwargs): 586 | ''' 587 | :parent: normal tkinter parent or master of the widget 588 | :toplevel: , if true will resize based off the toplevel etc 589 | :address: neovim connection info 590 | named pipe /tmp/nvim/1231 591 | tcp/ip socket 127.0.0.1:4444 592 | 'child' 593 | 'headless' 594 | :kwargs: config options for text widget 595 | ''' 596 | tk_util.Text.__init__(self, parent, **kwargs) 597 | self.nvim_handler = NvimHandler(text=self, 598 | toplevel=toplevel, 599 | address=address, 600 | debug_echo=False) 601 | 602 | # TODO weak ref? 603 | NvimTk.instances.append(self) 604 | 605 | def _nvimtk_config(self, *args): 606 | '''required config''' 607 | # Hide tkinter cursor 608 | self.config(insertontime=0) 609 | 610 | # Remove Default Bindings and what happens on insert etc 611 | bindtags = list(self.bindtags()) 612 | bindtags.remove("Text") 613 | self.bindtags(tuple(bindtags)) 614 | 615 | self.bind('', self.nvim_handler.tk_key_pressed) 616 | 617 | self.bind('', lambda e: self.focus_set()) 618 | 619 | # The negative number makes it pixels instead of point sizes 620 | size = self.make_font_size(13) 621 | self._fnormal = tkfont.Font(family='Monospace', size=size) 622 | self._fbold = tkfont.Font(family='Monospace', weight='bold', size=size) 623 | self._fitalic = tkfont.Font(family='Monospace', slant='italic', size=size) 624 | self._fbolditalic = tkfont.Font(family='Monospace', weight='bold', 625 | slant='italic', size=size) 626 | self.config(font=self._fnormal, wrap=tk.NONE) 627 | 628 | self.nvim_handler._colsize = self._fnormal.measure('M') 629 | self.nvim_handler._rowsize = self._fnormal.metrics('linespace') 630 | 631 | 632 | def nvim_connect(self, *a, **k): 633 | ''' force connection to neovim ''' 634 | self.nvim_handler.connect(*a, **k) 635 | self._nvimtk_config() 636 | 637 | @staticmethod 638 | def kill_all(): 639 | ''' Kill all the neovim connections ''' 640 | raise NotImplementedError 641 | for self in NvimTk.instances: 642 | if self.nvim_handler.connected: 643 | # Function hangs us.. 644 | # self.after(1, self.nvim_handler._bridge.exit) 645 | self.nvim_handler._bridge.exit() 646 | 647 | 648 | def pack(self, *arg, **kwarg): 649 | ''' connect to neovim if required''' 650 | tk_util.Text.pack(self, *arg, **kwarg) 651 | if not self.nvim_handler.connected: 652 | self.nvim_connect() 653 | 654 | self.nvim_handler.bind_resize() 655 | 656 | 657 | def grid(self, *arg, **kwarg): 658 | ''' connect to neovim if required''' 659 | tk_util.Text.grid(self, *arg, **kwarg) 660 | if not self.nvim_handler.connected: 661 | self.nvim_connect() 662 | 663 | self.nvim_handler.bind_resize() 664 | 665 | 666 | def schedule_screen_update(self, apply_updates): 667 | '''This function is called from the bridge, 668 | apply_updates calls the required nvim actions''' 669 | # if time.time() - self.start_time > 1: 670 | # print() 671 | # self.start_time = time.time() 672 | def do(): 673 | apply_updates() 674 | self.nvim_handler._flush() 675 | self.nvim_handler._start_blinking() 676 | self.master.after_idle(do) 677 | 678 | 679 | def quit(self): 680 | ''' destroy the widget, called from the bridge''' 681 | self.after_idle(self.destroy) 682 | 683 | # if __name__ == '__main__': 684 | # main() 685 | -------------------------------------------------------------------------------- /pytknvim/tk_util.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tkinter as tk 3 | 4 | 5 | class TkBlink(): 6 | def __init__(self, *args, **kwargs): 7 | super().__init__(*args, **kwargs) 8 | self._blink_timer_id = None 9 | self._blink_status = 'off' 10 | self._blink_time = 500 11 | 12 | 13 | def _do_blink(self): 14 | if self._blink_status == 'off': 15 | self._blink_status = 'on' 16 | self.tag_add('cursorblock', self._blink_pos) 17 | self.tag_config('cursorblock', 18 | background=self._blink_bg, 19 | foreground=self._blink_fg) 20 | else: 21 | self.tag_delete('cursorblock') 22 | self._blink_status = 'off' 23 | 24 | self._blink_timer_id = self.after(self._blink_time, 25 | self._do_blink) 26 | 27 | 28 | def blink_cursor(self, pos, fg, bg): 29 | ''' 30 | alternate the background color of the cursorblock tag 31 | self.blink_time = time inbetween blinks 32 | recall the function when pos/fg/bg change 33 | ''' 34 | if self._blink_timer_id: 35 | self.after_cancel(self._blink_timer_id) 36 | self._blink_pos = pos 37 | self._blink_bg = bg 38 | self._blink_fg = fg 39 | self._do_blink() 40 | 41 | 42 | def stop_blink(self): 43 | '''remove cursor from screen''' 44 | self.after_cancel(self._blink_timer_id) 45 | self.tag_delete('cursorblock') 46 | self._blink_status = 'off' 47 | 48 | 49 | class Text(TkBlink, tk.Text): 50 | def __init__(self, *args, **kwargs): 51 | super().__init__(*args, **kwargs) 52 | self._added_tags = {} 53 | 54 | 55 | def get_pos(self, row=None, col=None, mark=tk.INSERT): 56 | '''returns row and column as an int''' 57 | return (int(x) for x in self.index(mark).split('.')) 58 | 59 | 60 | def make_font_size(self, size): 61 | if os.name == 'nt': 62 | return size 63 | else: 64 | return size - 2 65 | 66 | 67 | def highlight_pattern(self, pattern, tag, start="1.0", 68 | end="end", regexp=False): 69 | '''Apply the given tag to all text that matches the 70 | given pattern 71 | If 'regexp' is set to True, pattern will be treated as a 72 | regular expression according to Tcl's regular 73 | expression syntax. 74 | ''' 75 | start = self.index(start) 76 | end = self.index(end) 77 | self.mark_set("matchStart", start) 78 | self.mark_set("matchEnd", start) 79 | self.mark_set("searchLimit", end) 80 | 81 | count = tk.IntVar() 82 | while True: 83 | index = self.search(pattern, "matchEnd","searchLimit", 84 | count=count, regexp=regexp) 85 | if index == "": 86 | break 87 | # degenerate pattern which matches zero-length strings 88 | if count.get() == 0: 89 | break 90 | self.mark_set("matchStart", index) 91 | self.mark_set("matchEnd", "%s+%sc" 92 | % (index, count.get())) 93 | self.tag_add(tag, "matchStart", "matchEnd") 94 | 95 | 96 | def get_endcol(self, row): 97 | ''' 98 | returns the index of the last char, not the newline char 99 | ''' 100 | end_col = int(self.index( 101 | str(row)+'.end-1c').split('.')[1]) 102 | return end_col 103 | 104 | 105 | def apply_attribute(self, style, start, end): 106 | # Ensure the attribute name is associated with a tag 107 | # configured with the corresponding attribute format 108 | for name, existing_style in self._added_tags.items(): 109 | # Style already exists 110 | if style == existing_style: 111 | break 112 | # Create a new 113 | else: 114 | name = self.make_name(style) 115 | self.font_from_style(name, style) 116 | self._added_tags[name] = style 117 | 118 | self.tag_add(name, start, end) 119 | 120 | 121 | def make_name(self, style): 122 | versions = [int(name[5:]) for name in \ 123 | self._added_tags.keys()] 124 | return 'nvim_' + str(self.unique_int(versions)) 125 | 126 | 127 | def font_from_style(self, name, style): 128 | '''configure font attributes''' 129 | # Get base font options 130 | new_font = tk.font.Font(self, self.cget("font")) 131 | for key, value in style.items(): 132 | if key == 'size': 133 | if os.name == 'posix': 134 | new_font.configure(size=int(value)-2) 135 | else: 136 | new_font.configure(size=value) 137 | else: 138 | try: 139 | eval('self.tag_configure(name, %s=value)'\ 140 | % key) 141 | except tk.TclError: 142 | eval('new_font.configure(%s=value)' % key) 143 | self.tag_configure(name, font=new_font) 144 | return new_font 145 | 146 | 147 | @staticmethod 148 | def unique_int(values): 149 | ''' 150 | if a list looks like 3,6 151 | if repeatedly called will return 1,2,4,5,7,8 152 | ''' 153 | last = 0 154 | for num in values: 155 | if last not in values: 156 | break 157 | else: 158 | last += 1 159 | return last 160 | -------------------------------------------------------------------------------- /pytknvim/ui_bridge.py: -------------------------------------------------------------------------------- 1 | """Bridge for connecting a UI instance to nvim.""" 2 | import sys 3 | from threading import Semaphore, Thread 4 | from traceback import format_exc 5 | 6 | 7 | class UIBridge(object): 8 | 9 | """UIBridge class. Connects a Nvim instance to a UI class.""" 10 | 11 | def connect(self, nvim, ui, profile=None, notify=False): 12 | """Connect nvim and the ui. 13 | 14 | This will start loops for handling the UI and nvim events while 15 | also synchronizing both. 16 | """ 17 | self._notify = notify 18 | self._error = None 19 | self._nvim = nvim 20 | self._ui = ui 21 | self._profile = profile 22 | self._sem = Semaphore(0) 23 | t = Thread(target=self._nvim_event_loop) 24 | t.daemon = True 25 | t.start() 26 | self._ui_event_loop() 27 | if self._error: 28 | print(self._error) 29 | if self._profile: 30 | print(self._profile) 31 | 32 | def exit(self): 33 | """Disconnect by exiting nvim.""" 34 | self.detach() 35 | self._call(self._nvim.quit) 36 | 37 | def input(self, input_str): 38 | """Send input to nvim.""" 39 | self._call(self._nvim.input, input_str) 40 | 41 | def resize(self, columns, rows): 42 | """Send a resize request to nvim.""" 43 | self._call(self._nvim.ui_try_resize, columns, rows) 44 | 45 | def attach(self, columns, rows, rgb): 46 | """Attach the UI to nvim.""" 47 | self._call(self._nvim.ui_attach, columns, rows, rgb) 48 | 49 | def detach(self): 50 | """Detach the UI from nvim.""" 51 | self._call(self._nvim.ui_detach) 52 | 53 | def _call(self, fn, *args): 54 | self._nvim.async_call(fn, *args) 55 | 56 | def _ui_event_loop(self): 57 | self._sem.acquire() 58 | # if self._profile: 59 | # import StringIO 60 | # import cProfile 61 | # import pstats 62 | # pr = cProfile.Profile() 63 | # pr.enable() 64 | # self._ui.start(self) 65 | # if self._profile: 66 | # pr.disable() 67 | # s = StringIO.StringIO() 68 | # ps = pstats.Stats(pr, stream=s) 69 | # ps.strip_dirs().sort_stats(self._profile).print_stats(30) 70 | # self._profile = s.getvalue() 71 | 72 | def _nvim_event_loop(self): 73 | def on_setup(): 74 | self._sem.release() 75 | 76 | def on_request(method, args): 77 | raise Exception('Not implemented') 78 | 79 | def on_notification(method, updates): 80 | def apply_updates(): 81 | if self._notify: 82 | sys.stdout.write('attached\n') 83 | sys.stdout.flush() 84 | self._notify = False 85 | try: 86 | for update in updates: 87 | # import sys 88 | # l = [','.join([str(a) for a in args]) 89 | # for args in update[1:]] 90 | # print >> sys.stderr, update[0], ' '.join(l) 91 | try: 92 | nvim_handler = getattr(self._ui, 'nvim_handler') 93 | handler = getattr(nvim_handler, '_nvim_' + update[0]) 94 | except AttributeError as err: 95 | pass 96 | else: 97 | for args in update[1:]: 98 | handler(*args) 99 | except Exception as err : 100 | print('ERROR OCCURED, unfortunalety no traceback..') 101 | import pdb;pdb.set_trace() 102 | self._error = format_exc() 103 | self._call(self._nvim.quit) 104 | if method == 'redraw': 105 | self._ui.schedule_screen_update(apply_updates) 106 | 107 | self._nvim.run_loop(on_request, on_notification, on_setup) 108 | self._ui.quit() 109 | -------------------------------------------------------------------------------- /pytknvim/util.py: -------------------------------------------------------------------------------- 1 | ''' 2 | some of these should go into neovim python client 3 | ''' 4 | from __future__ import print_function 5 | 6 | import os 7 | import time 8 | from subprocess import * 9 | from threading import Thread 10 | import shlex 11 | import string 12 | import random 13 | from functools import wraps 14 | from distutils.spawn import find_executable 15 | 16 | from neovim import attach 17 | 18 | def attach_socket(path=None): 19 | '''does it auto''' 20 | def open_nvim(): 21 | proc = Popen('NVIM_LISTEN_ADDRESS=/tmp/nvim nvim', 22 | stdin=PIPE, stdout=PIPE, shell=True) 23 | proc.communicate() 24 | 25 | # THIS IS DOESNT WORK UNRELIBALE 26 | #path = os.environ.get('NVIM_LISTEN_ADDRESS') 27 | if not path: 28 | print('threading') 29 | t = Thread(target=open_nvim) 30 | t.start() 31 | #todo make this give a random path 32 | return attach('socket', path='/tmp/nvim') 33 | else: 34 | print('attaching socket') 35 | return attach('socket', path=path) 36 | 37 | 38 | def attach_child(nvim_args, exec_name='nvim'): 39 | nvim_binary = find_executable(exec_name) 40 | args = [nvim_binary, '--embed'] 41 | if nvim_args: 42 | args.extend(nvim_args) 43 | return attach('child', argv=args) 44 | 45 | 46 | def attach_headless(nvim_args=None, path=None): 47 | if not path: 48 | path = '/tmp/nvim' + rand_str(8) 49 | os.environ['NVIM_LISTEN_ADDRESS'] = path 50 | dnull = open(os.devnull) 51 | # TODO WHY USE SHLEX??? 52 | cmd = shlex.split('nvim --headless') 53 | if nvim_args: 54 | cmd.extend(nvim_args) 55 | proc = Popen(cmd, 56 | stdin=dnull, 57 | stdout=dnull, 58 | stderr=dnull) 59 | dnull.close() 60 | while proc.poll() or proc.returncode is None: 61 | try: 62 | print('connected to headless socket', path) 63 | nvim = attach('socket', path=path) 64 | break 65 | except IOError: 66 | # Socket not yet ready 67 | time.sleep(0.05) 68 | 69 | return nvim 70 | 71 | 72 | def rand_str(length): 73 | '''returns a random string of length''' 74 | chars = [] 75 | for i in range(length): 76 | chars.append(random.choice(string.ascii_letters)) 77 | return ''.join(char for char in chars) 78 | 79 | 80 | def _stringify_key(key, state): 81 | send = [] 82 | if state == 'Shift': 83 | send.append('S') 84 | elif state == 'Ctrl': 85 | send.append('C') 86 | elif state =='Alt': 87 | send.append('A') 88 | send.append(key) 89 | return '<' + '-'.join(send) + '>' 90 | 91 | 92 | def _split_color(n): 93 | return ((n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff,) 94 | 95 | 96 | def _invert_color(r, g, b): 97 | return (255 - r, 255 - g, 255 - b,) 98 | 99 | 100 | def _stringify_color(r, g, b): 101 | return '#{0:0{1}x}'.format((r << 16) + (g << 8) + b, 6) 102 | 103 | 104 | def debug_echo(func): 105 | '''used on method to simply print the function name and 106 | parameters if self.debug_echo = True, 107 | the function will not execute''' 108 | @wraps(func) 109 | def deco(*args, **kwargs): 110 | try: 111 | debug = args[0].debug_echo 112 | except AttributeError: 113 | debug = False 114 | if debug: 115 | if len(args) == 1: 116 | to_print = [] 117 | else: 118 | to_print = args[1:] 119 | print(func.__name__, repr(to_print), **kwargs) 120 | 121 | return func(*args, **kwargs) 122 | return deco 123 | 124 | 125 | def rate_limited(max_per_second, mode='wait', delay_first_call=False): 126 | """ 127 | Decorator that make functions not be called faster than 128 | 129 | set mode to 'kill' to just ignore requests that are faster than the 130 | rate. 131 | 132 | set mode to 'refresh_timer' to reset the timer on successive calls 133 | 134 | set delay_first_call to True to delay the first call as well 135 | """ 136 | lock = threading.Lock() 137 | min_interval = 1.0 / float(max_per_second) 138 | def decorate(func): 139 | last_time_called = [0.0] 140 | @wraps(func) 141 | def rate_limited_function(*args, **kwargs): 142 | def run_func(): 143 | lock.release() 144 | ret = func(*args, **kwargs) 145 | last_time_called[0] = time.perf_counter() 146 | return ret 147 | lock.acquire() 148 | elapsed = time.perf_counter() - last_time_called[0] 149 | left_to_wait = min_interval - elapsed 150 | if delay_first_call: 151 | if left_to_wait > 0: 152 | if mode == 'wait': 153 | time.sleep(left_to_wait) 154 | return run_func() 155 | elif mode == 'kill': 156 | lock.release() 157 | return 158 | else: 159 | return run_func() 160 | else: 161 | if not last_time_called[0] or elapsed > min_interval: 162 | return run_func() 163 | elif mode == 'refresh_timer': 164 | print('Ref timer') 165 | lock.release() 166 | last_time_called[0] += time.perf_counter() 167 | return 168 | elif left_to_wait > 0: 169 | if mode == 'wait': 170 | time.sleep(left_to_wait) 171 | return run_func() 172 | elif mode == 'kill': 173 | lock.release() 174 | return 175 | return rate_limited_function 176 | return decorate 177 | 178 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 0 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup(name = 'pytknvim', 4 | version='0.1.6', 5 | description = 'tkinter text widget using neovim', 6 | url = 'https://github.com/timeyyy/pytknvim', 7 | author='timeyyy', 8 | author_email='tim_eichler@hotmail.com', 9 | license='BSD3', 10 | classifiers=[ 11 | 'Development Status :: 4 - Beta', 12 | 'Intended Audience :: Developers', 13 | 'License :: OSI Approved :: BSD License', 14 | 'Programming Language :: Python :: 3',], 15 | keywords = 'tkinter text neovim vim edit', 16 | packages=find_packages(exclude=['contrib', 'docs', 'tests*']), 17 | install_requires=['neovim>=0.1.3'], 18 | entry_points = { 19 | 'console_scripts': [ 20 | 'pytknvim=pytknvim.tk_ui:main',],},) 21 | --------------------------------------------------------------------------------