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
|
|
|
|