Completed
Push — master ( 2bc9f2...ef56ad )
by Lambda
01:45
created

Keymap.harvest()   D

Complexity

Conditions 8

Size

Total Lines 44

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 8
dl 0
loc 44
rs 4
c 0
b 0
f 0
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