|
1
|
|
|
"""Keymap.""" |
|
2
|
|
|
import time |
|
3
|
|
|
from operator import itemgetter |
|
4
|
|
|
from datetime import datetime, timedelta |
|
5
|
|
|
from typing import cast, Iterator, Optional, Sequence, Tuple, Union |
|
6
|
|
|
from neovim import Nvim |
|
7
|
|
|
from .key import Key, KeyCode |
|
8
|
|
|
from .keystroke import Keystroke, KeystrokeExpr |
|
9
|
|
|
from .util import getchar |
|
10
|
|
|
|
|
11
|
|
|
Rule = Union[ |
|
12
|
|
|
Tuple[KeystrokeExpr, KeystrokeExpr], |
|
13
|
|
|
Tuple[KeystrokeExpr, KeystrokeExpr, bool], |
|
14
|
|
|
Tuple[KeystrokeExpr, KeystrokeExpr, bool, bool], |
|
15
|
|
|
] |
|
16
|
|
|
|
|
17
|
|
|
|
|
18
|
|
|
class Keymap: |
|
19
|
|
|
"""Keymap.""" |
|
20
|
|
|
|
|
21
|
|
|
__slots__ = ('registry',) |
|
22
|
|
|
|
|
23
|
|
|
def __init__(self) -> None: |
|
24
|
|
|
"""Constructor.""" |
|
25
|
|
|
self.registry = {} # type: Dict[Keystroke, tuple] |
|
26
|
|
|
|
|
27
|
|
|
def register(self, |
|
28
|
|
|
lhs: Keystroke, |
|
29
|
|
|
rhs: Keystroke, |
|
30
|
|
|
noremap: bool=False, |
|
31
|
|
|
nowait: bool=False) -> None: |
|
32
|
|
|
"""Register a keymap. |
|
33
|
|
|
|
|
34
|
|
|
Args: |
|
35
|
|
|
lhs (Keystroke): A left hand side Keystroke instance of the |
|
36
|
|
|
mapping. |
|
37
|
|
|
rhs (Keystroke): A right hand side Keystroke instance of the |
|
38
|
|
|
mapping. |
|
39
|
|
|
noremap (bool): A boolean to indicate noremap in Vim. |
|
40
|
|
|
nowait (bool): A boolean to indicate nowait in Vim. |
|
41
|
|
|
|
|
42
|
|
|
Example: |
|
43
|
|
|
>>> from neovim_prompt.keystroke import Keystroke |
|
44
|
|
|
>>> from unittest.mock import MagicMock |
|
45
|
|
|
>>> nvim = MagicMock() |
|
46
|
|
|
>>> nvim.options = {'encoding': 'utf-8'} |
|
47
|
|
|
>>> keymap = Keymap() |
|
48
|
|
|
>>> keymap.register( |
|
49
|
|
|
... Keystroke.parse(nvim, '<C-H>'), |
|
50
|
|
|
... Keystroke.parse(nvim, '<BS>'), |
|
51
|
|
|
... ) |
|
52
|
|
|
>>> keymap.register( |
|
53
|
|
|
... Keystroke.parse(nvim, '<C-H>'), |
|
54
|
|
|
... Keystroke.parse(nvim, '<BS>'), |
|
55
|
|
|
... noremap=True, |
|
56
|
|
|
... ) |
|
57
|
|
|
>>> keymap.register( |
|
58
|
|
|
... Keystroke.parse(nvim, '<C-H>'), |
|
59
|
|
|
... Keystroke.parse(nvim, '<BS>'), |
|
60
|
|
|
... nowait=True, |
|
61
|
|
|
... ) |
|
62
|
|
|
>>> keymap.register( |
|
63
|
|
|
... Keystroke.parse(nvim, '<C-H>'), |
|
64
|
|
|
... Keystroke.parse(nvim, '<BS>'), |
|
65
|
|
|
... noremap=True, |
|
66
|
|
|
... nowait=True, |
|
67
|
|
|
... ) |
|
68
|
|
|
|
|
69
|
|
|
""" |
|
70
|
|
|
self.registry[lhs] = (lhs, rhs, noremap, nowait) |
|
71
|
|
|
|
|
72
|
|
|
def register_from_rule(self, nvim: Nvim, rule: Rule) -> None: |
|
73
|
|
|
"""Register a keymap from a rule. |
|
74
|
|
|
|
|
75
|
|
|
Args: |
|
76
|
|
|
nvim (neovim.Nvim): A ``neovim.Nvim`` instance. |
|
77
|
|
|
rule (tuple): A rule tuple. The rule is arguments of |
|
78
|
|
|
``Key.register`` method. |
|
79
|
|
|
|
|
80
|
|
|
Example: |
|
81
|
|
|
>>> from neovim_prompt.keystroke import Keystroke |
|
82
|
|
|
>>> from unittest.mock import MagicMock |
|
83
|
|
|
>>> nvim = MagicMock() |
|
84
|
|
|
>>> nvim.options = {'encoding': 'utf-8'} |
|
85
|
|
|
>>> keymap = Keymap() |
|
86
|
|
|
>>> keymap.register_from_rule(nvim, [ |
|
87
|
|
|
... Keystroke.parse(nvim, '<C-H>'), |
|
88
|
|
|
... Keystroke.parse(nvim, '<BS>'), |
|
89
|
|
|
... ]) |
|
90
|
|
|
>>> keymap.register_from_rule(nvim, [ |
|
91
|
|
|
... Keystroke.parse(nvim, '<C-H>'), |
|
92
|
|
|
... Keystroke.parse(nvim, '<BS>'), |
|
93
|
|
|
... True, |
|
94
|
|
|
... ]) |
|
95
|
|
|
>>> keymap.register_from_rule(nvim, [ |
|
96
|
|
|
... Keystroke.parse(nvim, '<C-H>'), |
|
97
|
|
|
... Keystroke.parse(nvim, '<BS>'), |
|
98
|
|
|
... True, True, |
|
99
|
|
|
... ]) |
|
100
|
|
|
|
|
101
|
|
|
""" |
|
102
|
|
|
if len(rule) == 2: |
|
103
|
|
|
lhs, rhs = cast('Tuple[KeystrokeExpr, KeystrokeExpr]', rule) |
|
104
|
|
|
noremap = False |
|
105
|
|
|
nowait = False |
|
106
|
|
|
elif len(rule) == 3: |
|
107
|
|
|
lhs, rhs, noremap = cast( |
|
108
|
|
|
'Tuple[KeystrokeExpr, KeystrokeExpr, bool]', |
|
109
|
|
|
rule |
|
110
|
|
|
) |
|
111
|
|
|
nowait = False |
|
112
|
|
|
else: |
|
113
|
|
|
lhs, rhs, noremap, nowait = cast( |
|
114
|
|
|
'Tuple[KeystrokeExpr, KeystrokeExpr, bool, bool]', |
|
115
|
|
|
rule |
|
116
|
|
|
) |
|
117
|
|
|
lhs = Keystroke.parse(nvim, lhs) |
|
118
|
|
|
rhs = Keystroke.parse(nvim, rhs) |
|
119
|
|
|
self.register(lhs, rhs, noremap, nowait) |
|
120
|
|
|
|
|
121
|
|
|
def register_from_rules(self, |
|
122
|
|
|
nvim: Nvim, |
|
123
|
|
|
rules: Sequence[Rule]) -> None: |
|
124
|
|
|
"""Register keymaps from rule tuple. |
|
125
|
|
|
|
|
126
|
|
|
Args: |
|
127
|
|
|
nvim (neovim.Nvim): A ``neovim.Nvim`` instance. |
|
128
|
|
|
rules (tuple): A tuple of rules. |
|
129
|
|
|
|
|
130
|
|
|
Example: |
|
131
|
|
|
>>> from neovim_prompt.keystroke import Keystroke |
|
132
|
|
|
>>> from unittest.mock import MagicMock |
|
133
|
|
|
>>> nvim = MagicMock() |
|
134
|
|
|
>>> nvim.options = {'encoding': 'utf-8'} |
|
135
|
|
|
>>> lhs1 = Keystroke.parse(nvim, '<C-H>') |
|
136
|
|
|
>>> lhs2 = Keystroke.parse(nvim, '<C-D>') |
|
137
|
|
|
>>> lhs3 = Keystroke.parse(nvim, '<C-M>') |
|
138
|
|
|
>>> rhs1 = Keystroke.parse(nvim, '<BS>') |
|
139
|
|
|
>>> rhs2 = Keystroke.parse(nvim, '<DEL>') |
|
140
|
|
|
>>> rhs3 = Keystroke.parse(nvim, '<CR>') |
|
141
|
|
|
>>> keymap = Keymap() |
|
142
|
|
|
>>> keymap.register_from_rules(nvim, [ |
|
143
|
|
|
... (lhs1, rhs1), |
|
144
|
|
|
... (lhs2, rhs2, True), |
|
145
|
|
|
... (lhs3, rhs3, False, True), |
|
146
|
|
|
... ]) |
|
147
|
|
|
|
|
148
|
|
|
""" |
|
149
|
|
|
for rule in rules: |
|
150
|
|
|
self.register_from_rule(nvim, rule) |
|
151
|
|
|
|
|
152
|
|
|
def filter(self, lhs: Keystroke) -> Iterator[Keystroke]: |
|
153
|
|
|
"""Filter keymaps by ``lhs`` Keystroke and return a sorted candidates. |
|
154
|
|
|
|
|
155
|
|
|
Args: |
|
156
|
|
|
lhs (Keystroke): A left hand side Keystroke instance. |
|
157
|
|
|
|
|
158
|
|
|
Example: |
|
159
|
|
|
>>> from neovim_prompt.keystroke import Keystroke |
|
160
|
|
|
>>> from unittest.mock import MagicMock |
|
161
|
|
|
>>> nvim = MagicMock() |
|
162
|
|
|
>>> nvim.options = {'encoding': 'utf-8'} |
|
163
|
|
|
>>> k = lambda x: Keystroke.parse(nvim, x) |
|
164
|
|
|
>>> keymap = Keymap() |
|
165
|
|
|
>>> keymap.register_from_rules(nvim, [ |
|
166
|
|
|
... (k('<C-A><C-A>'), k('<prompt:A>')), |
|
167
|
|
|
... (k('<C-A><C-B>'), k('<prompt:B>')), |
|
168
|
|
|
... (k('<C-B><C-A>'), k('<prompt:C>')), |
|
169
|
|
|
... ]) |
|
170
|
|
|
>>> candidates = keymap.filter(k('')) |
|
171
|
|
|
>>> len(candidates) |
|
172
|
|
|
3 |
|
173
|
|
|
>>> candidates[0] |
|
174
|
|
|
((Key(...), Key(...)), (Key(code=b'<prompt:A>', ...) |
|
175
|
|
|
>>> candidates[1] |
|
176
|
|
|
((Key(...), Key(...)), (Key(code=b'<prompt:B>', ...) |
|
177
|
|
|
>>> candidates[2] |
|
178
|
|
|
((Key(...), Key(...)), (Key(code=b'<prompt:C>', ...) |
|
179
|
|
|
>>> candidates = keymap.filter(k('<C-A>')) |
|
180
|
|
|
>>> len(candidates) |
|
181
|
|
|
2 |
|
182
|
|
|
>>> candidates[0] |
|
183
|
|
|
((Key(...), Key(...)), (Key(code=b'<prompt:A>', ...) |
|
184
|
|
|
>>> candidates[1] |
|
185
|
|
|
((Key(...), Key(...)), (Key(code=b'<prompt:B>', ...) |
|
186
|
|
|
>>> candidates = keymap.filter(k('<C-A><C-A>')) |
|
187
|
|
|
>>> len(candidates) |
|
188
|
|
|
1 |
|
189
|
|
|
>>> candidates[0] |
|
190
|
|
|
((Key(...), Key(...)), (Key(code=b'<prompt:A>', ...) |
|
191
|
|
|
|
|
192
|
|
|
Returns: |
|
193
|
|
|
Iterator[Keystroke]: Sorted Keystroke instances which starts from |
|
194
|
|
|
`lhs` Keystroke instance |
|
195
|
|
|
""" |
|
196
|
|
|
candidates = ( |
|
197
|
|
|
self.registry[k] |
|
198
|
|
|
for k in self.registry.keys() if k.startswith(lhs) |
|
199
|
|
|
) |
|
200
|
|
|
return cast( |
|
201
|
|
|
'Iterator[Keystroke]', |
|
202
|
|
|
sorted(candidates, key=itemgetter(0)), |
|
203
|
|
|
) |
|
204
|
|
|
|
|
205
|
|
|
def resolve(self, |
|
206
|
|
|
lhs: Keystroke, |
|
207
|
|
|
nowait: bool=False) -> Optional[Keystroke]: |
|
208
|
|
|
"""Resolve ``lhs`` Keystroke instance and return resolved keystroke. |
|
209
|
|
|
|
|
210
|
|
|
Args: |
|
211
|
|
|
lhs (Keystroke): A left hand side Keystroke instance. |
|
212
|
|
|
nowait (bool): Return a first exact matched keystroke even there |
|
213
|
|
|
are multiple keystroke instances are matched. |
|
214
|
|
|
|
|
215
|
|
|
Example: |
|
216
|
|
|
>>> from neovim_prompt.keystroke import Keystroke |
|
217
|
|
|
>>> from unittest.mock import MagicMock |
|
218
|
|
|
>>> nvim = MagicMock() |
|
219
|
|
|
>>> nvim.options = {'encoding': 'utf-8'} |
|
220
|
|
|
>>> k = lambda x: Keystroke.parse(nvim, x) |
|
221
|
|
|
>>> keymap = Keymap() |
|
222
|
|
|
>>> keymap.register_from_rules(nvim, [ |
|
223
|
|
|
... (k('<C-A><C-A>'), k('<prompt:A>')), |
|
224
|
|
|
... (k('<C-A><C-B>'), k('<prompt:B>')), |
|
225
|
|
|
... (k('<C-B><C-A>'), k('<C-A><C-A>'), False), |
|
226
|
|
|
... (k('<C-B><C-B>'), k('<C-A><C-B>'), True), |
|
227
|
|
|
... (k('<C-C>'), k('<prompt:C>'), False, False), |
|
228
|
|
|
... (k('<C-C><C-A>'), k('<prompt:C1>')), |
|
229
|
|
|
... (k('<C-C><C-B>'), k('<prompt:C2>')), |
|
230
|
|
|
... (k('<C-D>'), k('<prompt:D>'), False, True), |
|
231
|
|
|
... (k('<C-D><C-A>'), k('<prompt:D1>')), |
|
232
|
|
|
... (k('<C-D><C-B>'), k('<prompt:D2>')), |
|
233
|
|
|
... ]) |
|
234
|
|
|
>>> # No mapping starts from <C-C> so <C-C> is returned |
|
235
|
|
|
>>> keymap.resolve(k('<C-Z>')) |
|
236
|
|
|
(Key(code=26, ...),) |
|
237
|
|
|
>>> # No single keystroke is resolved in the following case so None |
|
238
|
|
|
>>> # will be returned. |
|
239
|
|
|
>>> keymap.resolve(k('')) is None |
|
240
|
|
|
True |
|
241
|
|
|
>>> keymap.resolve(k('<C-A>')) is None |
|
242
|
|
|
True |
|
243
|
|
|
>>> # A single keystroke is resolved so rhs is returned. |
|
244
|
|
|
>>> # will be returned. |
|
245
|
|
|
>>> keymap.resolve(k('<C-A><C-A>')) |
|
246
|
|
|
(Key(code=b'<prompt:A>', ...),) |
|
247
|
|
|
>>> keymap.resolve(k('<C-A><C-B>')) |
|
248
|
|
|
(Key(code=b'<prompt:B>', ...),) |
|
249
|
|
|
>>> # noremap = False so recursively resolved |
|
250
|
|
|
>>> keymap.resolve(k('<C-B><C-A>')) |
|
251
|
|
|
(Key(code=b'<prompt:A>', ...),) |
|
252
|
|
|
>>> # noremap = True so resolved only once |
|
253
|
|
|
>>> keymap.resolve(k('<C-B><C-B>')) |
|
254
|
|
|
(Key(code=1, ...), Key(code=2, ...)) |
|
255
|
|
|
>>> # nowait = False so no single keystroke could be resolved. |
|
256
|
|
|
>>> keymap.resolve(k('<C-C>')) is None |
|
257
|
|
|
True |
|
258
|
|
|
>>> # nowait = True so the first matched candidate is returned. |
|
259
|
|
|
>>> keymap.resolve(k('<C-D>')) |
|
260
|
|
|
(Key(code=b'<prompt:D>', ...),) |
|
261
|
|
|
|
|
262
|
|
|
Returns: |
|
263
|
|
|
None or Keystroke: None if no single keystroke instance is |
|
264
|
|
|
resolved. Otherwise return a resolved keystroke instance or |
|
265
|
|
|
``lhs`` itself if no mapping is available for ``lhs`` |
|
266
|
|
|
keystroke. |
|
267
|
|
|
""" |
|
268
|
|
|
candidates = list(self.filter(lhs)) |
|
269
|
|
|
n = len(candidates) |
|
270
|
|
|
if n == 0: |
|
271
|
|
|
return lhs |
|
272
|
|
|
elif n == 1: |
|
273
|
|
|
_lhs, rhs, noremap, _nowait = candidates[0] |
|
274
|
|
|
if lhs == _lhs: |
|
275
|
|
|
return rhs if noremap else self.resolve(rhs, nowait=True) |
|
276
|
|
|
elif nowait: |
|
277
|
|
|
# Use the first matched candidate if lhs is equal |
|
278
|
|
|
_lhs, rhs, noremap, _nowait = candidates[0] |
|
279
|
|
|
if lhs == _lhs: |
|
280
|
|
|
return rhs if noremap else self.resolve(rhs, nowait=True) |
|
281
|
|
|
else: |
|
282
|
|
|
# Check if the current first candidate is defined as nowait |
|
283
|
|
|
_lhs, rhs, noremap, nowait = candidates[0] |
|
284
|
|
|
if nowait and lhs == _lhs: |
|
285
|
|
|
return rhs if noremap else self.resolve(rhs, nowait=True) |
|
286
|
|
|
return None |
|
287
|
|
|
|
|
288
|
|
|
def harvest(self, nvim: Nvim) -> Keystroke: |
|
289
|
|
|
"""Harvest a keystroke from getchar in Vim and return resolved. |
|
290
|
|
|
|
|
291
|
|
|
It reads 'timeout' and 'timeoutlen' options in Vim and harvest a |
|
292
|
|
|
keystroke as Vim does. For example, if there is a key mapping for |
|
293
|
|
|
<C-X><C-F>, it waits 'timeoutlen' milliseconds after user hit <C-X>. |
|
294
|
|
|
If user continue <C-F> within timeout, it returns <C-X><C-F>. Otherwise |
|
295
|
|
|
it returns <C-X> before user continue <C-F>. |
|
296
|
|
|
If 'timeout' options is 0, it wait the next hit forever. |
|
297
|
|
|
|
|
298
|
|
|
Note that it returns a key immediately if the key is not a part of the |
|
299
|
|
|
registered mappings. |
|
300
|
|
|
|
|
301
|
|
|
Args: |
|
302
|
|
|
nvim (neovim.Nvim): A ``neovim.Nvim`` instance. |
|
303
|
|
|
|
|
304
|
|
|
Returns: |
|
305
|
|
|
Keystroke: A resolved keystroke. |
|
306
|
|
|
|
|
307
|
|
|
""" |
|
308
|
|
|
if nvim.options['timeout']: |
|
309
|
|
|
timeoutlen = timedelta( |
|
310
|
|
|
milliseconds=int(nvim.options['timeoutlen']) |
|
311
|
|
|
) |
|
312
|
|
|
else: |
|
313
|
|
|
timeoutlen = None |
|
314
|
|
|
|
|
315
|
|
|
previous = None |
|
316
|
|
|
while True: |
|
317
|
|
|
code = _getcode( |
|
318
|
|
|
nvim, |
|
319
|
|
|
datetime.now() + timeoutlen if timeoutlen else None |
|
320
|
|
|
) |
|
321
|
|
|
if code is None and previous is None: |
|
322
|
|
|
# timeout without input |
|
323
|
|
|
continue |
|
324
|
|
|
elif code is None: |
|
325
|
|
|
# timeout |
|
326
|
|
|
return self.resolve(previous, nowait=True) or previous |
|
327
|
|
|
previous = Keystroke((previous or ()) + (Key.parse(nvim, code),)) |
|
328
|
|
|
keystroke = self.resolve(previous, nowait=False) |
|
329
|
|
|
if keystroke: |
|
330
|
|
|
# resolved |
|
331
|
|
|
return keystroke |
|
332
|
|
|
|
|
333
|
|
|
@classmethod |
|
334
|
|
|
def from_rules(cls, nvim: Nvim, rules: Sequence[Rule]) -> 'Keymap': |
|
335
|
|
|
"""Create a keymap instance from a rule tuple. |
|
336
|
|
|
|
|
337
|
|
|
Args: |
|
338
|
|
|
nvim (neovim.Nvim): A ``neovim.Nvim`` instance. |
|
339
|
|
|
rules (tuple): A tuple of rules. |
|
340
|
|
|
|
|
341
|
|
|
Example: |
|
342
|
|
|
>>> from neovim_prompt.keystroke import Keystroke |
|
343
|
|
|
>>> from unittest.mock import MagicMock |
|
344
|
|
|
>>> nvim = MagicMock() |
|
345
|
|
|
>>> nvim.options = {'encoding': 'utf-8'} |
|
346
|
|
|
>>> lhs1 = Keystroke.parse(nvim, '<C-H>') |
|
347
|
|
|
>>> lhs2 = Keystroke.parse(nvim, '<C-D>') |
|
348
|
|
|
>>> lhs3 = Keystroke.parse(nvim, '<C-M>') |
|
349
|
|
|
>>> rhs1 = Keystroke.parse(nvim, '<BS>') |
|
350
|
|
|
>>> rhs2 = Keystroke.parse(nvim, '<DEL>') |
|
351
|
|
|
>>> rhs3 = Keystroke.parse(nvim, '<CR>') |
|
352
|
|
|
>>> keymap = Keymap.from_rules(nvim, [ |
|
353
|
|
|
... (lhs1, rhs1), |
|
354
|
|
|
... (lhs2, rhs2, True), |
|
355
|
|
|
... (lhs3, rhs3, False, True), |
|
356
|
|
|
... ]) |
|
357
|
|
|
|
|
358
|
|
|
Returns: |
|
359
|
|
|
Keymap: A keymap instance |
|
360
|
|
|
""" |
|
361
|
|
|
keymap = cls() |
|
362
|
|
|
keymap.register_from_rules(nvim, rules) |
|
363
|
|
|
return keymap |
|
364
|
|
|
|
|
365
|
|
|
|
|
366
|
|
|
def _getcode(nvim: 'Nvim', timeout: Optional[datetime]) -> Optional[KeyCode]: |
|
367
|
|
|
while not timeout or timeout > datetime.now(): |
|
368
|
|
|
code = getchar(nvim, False) |
|
369
|
|
|
if code != 0: |
|
370
|
|
|
return code |
|
371
|
|
|
time.sleep(0.01) |
|
372
|
|
|
return None |
|
373
|
|
|
|
|
374
|
|
|
|
|
375
|
|
|
DEFAULT_KEYMAP_RULES = ( |
|
376
|
|
|
('<CR>', '<prompt:accept>', True), |
|
377
|
|
|
('<ESC>', '<prompt:cancel>', True), |
|
378
|
|
|
('<INSERT>', '<prompt:toggle_insert_mode>', True), |
|
379
|
|
|
('<BS>', '<prompt:delete_char_before_caret>', True), |
|
380
|
|
|
('<DEL>', '<prompt:delete_char_under_caret>', True), |
|
381
|
|
|
('<C-U>', '<prompt:delete_entire_text>', True), |
|
382
|
|
|
('<Left>', '<prompt:move_caret_to_left>', True), |
|
383
|
|
|
('<Right>', '<prompt:move_caret_to_right>', True), |
|
384
|
|
|
('<Home>', '<prompt:move_caret_to_head>', True), |
|
385
|
|
|
('<End>', '<prompt:move_caret_to_tail>', True), |
|
386
|
|
|
('<C-P>', '<prompt:assign_previous_text>', True), |
|
387
|
|
|
('<C-N>', '<prompt:assign_next_text>', True), |
|
388
|
|
|
('<Up>', '<prompt:assign_previous_matched_text>', True), |
|
389
|
|
|
('<Down>', '<prompt:assign_next_matched_text>', True), |
|
390
|
|
|
('<C-R>', '<prompt:paste_from_register>', True), |
|
391
|
|
|
) |
|
392
|
|
|
|