Completed
Push — master ( 2e9895...6383ba )
by Lambda
01:23
created

Prompt.insert_text()   B

Complexity

Conditions 1

Size

Total Lines 27

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
dl 0
loc 27
rs 8.8571
c 0
b 0
f 0
1
"""Prompt module."""
2
import re
3
import copy
4
from datetime import timedelta
5
6
ACTION_KEYSTROKE_PATTERN = re.compile(r'<(\w+:\w+)>')
7
8
ESCAPE_ECHO = str.maketrans({
9
    '"': '\\"',
10
    '\\': '\\\\',
11
})
12
13
IMPRINTABLE_REPRESENTS = {
14
    '\a': '^G',
15
    '\b': '^H',             # NOTE: Neovim: <BS>, Vim: ^H. Follow Vim.
16
    '\t': '^I',
17
    '\n': '^J',
18
    '\v': '^K',
19
    '\f': '^L',
20
    '\r': '^M',
21
    '\udc80\udcffX': '^@',  # NOTE: ^0 representation in Vim.
22
}
23
24
IMPRINTABLE_PATTERN = re.compile(r'(%s)' % '|'.join(
25
    IMPRINTABLE_REPRESENTS.keys()
26
))
27
28
29
STATUS_PROGRESS = 0
30
STATUS_ACCEPT = 1
31
STATUS_CANCEL = 2
32
STATUS_ERROR = 3
33
34
INSERT_MODE_INSERT = 1
35
INSERT_MODE_REPLACE = 2
36
37
38
class Prompt:
39
    """Prompt class."""
40
41
    prefix = ''
42
43
    def __init__(self, nvim, context):
44
        """Constructor.
45
46
        Args:
47
            nvim (neovim.Nvim): A ``neovim.Nvim`` instance.
48
            context (Context): A ``neovim_prompt.context.Context`` instance.
49
        """
50
        from .caret import Caret
51
        from .history import History
52
        from .keymap import DEFAULT_KEYMAP_RULES, Keymap
53
        from .action import DEFAULT_ACTION
54
        self.nvim = nvim
55
        self.insert_mode = INSERT_MODE_INSERT
56
        self.context = context
57
        self.caret = Caret(context)
58
        self.history = History(self)
59
        self.action = copy.copy(DEFAULT_ACTION)     # type: ignore
60
        self.keymap = Keymap.from_rules(nvim, DEFAULT_KEYMAP_RULES)
61
62
    @property
63
    def text(self):
64
        """str: A current context text.
65
66
        It automatically adjust the current caret locus to the tail of the text
67
        if any text is assigned.
68
69
        It calls the following overridable methods in order of the appearance.
70
71
        - on_init - Only once
72
        - on_update
73
        - on_redraw
74
        - on_keypress
75
        - on_term - Only once
76
77
        Example:
78
            >>> from neovim_prompt.context import Context
79
            >>> from unittest.mock import MagicMock
80
            >>> nvim = MagicMock()
81
            >>> nvim.options = {'encoding': 'utf-8'}
82
            >>> context = Context()
83
            >>> context.text = "Hello"
84
            >>> context.caret_locus = 3
85
            >>> prompt = Prompt(nvim, context)
86
            >>> prompt.text
87
            'Hello'
88
            >>> prompt.caret.locus
89
            3
90
            >>> prompt.text = "FooFooFoo"
91
            >>> prompt.text
92
            'FooFooFoo'
93
            >>> prompt.caret.locus
94
            9
95
        """
96
        return self.context.text
97
98
    @text.setter
99
    def text(self, value):
100
        self.context.text = value
101
        self.caret.locus = len(value)
102
103
    def apply_custom_mappings_from_vim_variable(self, varname):
104
        """Apply custom key mappings from Vim variable.
105
106
        Args:
107
            varname (str): A global Vim's variable name
108
        """
109
        if varname in self.nvim.vars:
110
            custom_mappings = self.nvim.vars[varname]
111
            for rule in custom_mappings:
112
                self.keymap.register_from_rule(self.nvim, rule)
113
114
    def insert_text(self, text):
115
        """Insert text after the caret.
116
117
        Args:
118
            text (str): A text which will be inserted after the caret.
119
120
        Example:
121
            >>> from neovim_prompt.context import Context
122
            >>> from unittest.mock import MagicMock
123
            >>> nvim = MagicMock()
124
            >>> nvim.options = {'encoding': 'utf-8'}
125
            >>> context = Context()
126
            >>> context.text = "Hello Goodbye"
127
            >>> context.caret_locus = 3
128
            >>> prompt = Prompt(nvim, context)
129
            >>> prompt.insert_text('AA')
130
            >>> prompt.text
131
            'HelAAlo Goodbye'
132
        """
133
        locus = self.caret.locus
134
        self.text = ''.join([
135
            self.caret.get_backward_text(),
136
            text,
137
            self.caret.get_selected_text(),
138
            self.caret.get_forward_text(),
139
        ])
140
        self.caret.locus = locus + len(text)
141
142
    def replace_text(self, text):
143
        """Replace text after the caret.
144
145
        Args:
146
            text (str): A text which will be replaced after the caret.
147
148
        Example:
149
            >>> from neovim_prompt.context import Context
150
            >>> from unittest.mock import MagicMock
151
            >>> nvim = MagicMock()
152
            >>> nvim.options = {'encoding': 'utf-8'}
153
            >>> context = Context()
154
            >>> context.text = "Hello Goodbye"
155
            >>> context.caret_locus = 3
156
            >>> prompt = Prompt(nvim, context)
157
            >>> prompt.replace_text('AA')
158
            >>> prompt.text
159
            'HelAA Goodbye'
160
        """
161
        locus = self.caret.locus
162
        self.text = ''.join([
163
            self.caret.get_backward_text(),
164
            text,
165
            self.caret.get_forward_text()[len(text) - 1:],
166
        ])
167
        self.caret.locus = locus + len(text)
168
169
    def update_text(self, text):
170
        """Insert or replace text after the caret.
171
172
        Args:
173
            text (str): A text which will be replaced after the caret.
174
175
        Example:
176
            >>> from neovim_prompt.context import Context
177
            >>> from unittest.mock import MagicMock
178
            >>> nvim = MagicMock()
179
            >>> nvim.options = {'encoding': 'utf-8'}
180
            >>> context = Context()
181
            >>> context.text = "Hello Goodbye"
182
            >>> context.caret_locus = 3
183
            >>> prompt = Prompt(nvim, context)
184
            >>> prompt.insert_mode = INSERT_MODE_INSERT
185
            >>> prompt.update_text('AA')
186
            >>> prompt.text
187
            'HelAAlo Goodbye'
188
            >>> prompt.insert_mode = INSERT_MODE_REPLACE
189
            >>> prompt.update_text('BB')
190
            >>> prompt.text
191
            'HelAABB Goodbye'
192
        """
193
        if self.insert_mode == INSERT_MODE_INSERT:
194
            self.insert_text(text)
195
        else:
196
            self.replace_text(text)
197
198
    def redraw_prompt(self):
199
        # NOTE:
200
        # There is a highlight name 'Cursor' but some sometime the visibility
201
        # is quite low (e.g. tender) so use 'IncSearch' instead while the
202
        # visibility is quite good and most recent colorscheme care about it.
203
        backward_text = self.caret.get_backward_text()
204
        selected_text = self.caret.get_selected_text()
205
        forward_text = self.caret.get_forward_text()
206
        self.nvim.command('|'.join([
207
            'redraw',
208
            _build_echon_expr('Question', self.prefix),
209
            _build_echon_expr('None', backward_text),
210
            _build_echon_expr('IncSearch', selected_text),
211
            _build_echon_expr('None', forward_text),
212
        ]))
213
214
    def start(self, default=None):
215
        """Start prompt with ``default`` text and return value.
216
217
        Args:
218
            default (None or str): A default text of the prompt. If omitted, a
219
                text in the context specified in the constructor is used.
220
221
        Returns:
222
            int: The status of the prompt.
223
        """
224
        status = self.on_init(default) or STATUS_PROGRESS
225
        if self.nvim.options['timeout']:
226
            timeoutlen = timedelta(
227
                milliseconds=int(self.nvim.options['timeoutlen'])
228
            )
229
        else:
230
            timeoutlen = None
231
        try:
232
            status = self.on_update(status) or STATUS_PROGRESS
233
            while status is STATUS_PROGRESS:
234
                self.on_redraw()
235
                status = self.on_keypress(
236
                    self.keymap.harvest(self.nvim, timeoutlen)
237
                ) or STATUS_PROGRESS
238
                status = self.on_update(status) or STATUS_PROGRESS
239
        except KeyboardInterrupt:
240
            status = STATUS_CANCEL
241
        except self.nvim.error as e:
242
            self.nvim.command('|'.join([
243
                'echoerr "%s"' % line.translate(ESCAPE_ECHO)
244
                for line in str(e).splitlines()
245
            ]))
246
            status = STATUS_ERROR
247
        self.nvim.command('redraw!')
248
        if self.text:
249
            self.nvim.call('histadd', 'input', self.text)
250
        return self.on_term(status)
251
252
    def on_init(self, default):
253
        """Initialize the prompt.
254
255
        It calls 'inputsave' function in Vim and assign ``default`` text to the
256
        ``self.text`` to initialize the prompt text in default.
257
258
        Args:
259
            default (None or str): A default text of the prompt. If omitted, a
260
                text in the context specified in the constructor is used.
261
262
        Returns:
263
            None or int: The return value will be used as a status of the
264
                prompt mainloop, indicating that if return value is not
265
                STATUS_PROGRESS, the prompt mainloop immediately terminated.
266
                Returning None is equal to returning STATUS_PROGRESS.
267
        """
268
        self.nvim.call('inputsave')
269
        if default:
270
            self.text = default
271
272
    def on_update(self, status):
273
        """Update the prompt status and return the status.
274
275
        It is used to update the prompt status. In default, it does nothing and
276
        return the specified ``status`` directly.
277
278
        Args:
279
            status (int): A prompt status which is updated by previous
280
                on_keypress call.
281
282
        Returns:
283
            None or int: The return value will be used as a status of the
284
                prompt mainloop, indicating that if return value is not
285
                STATUS_PROGRESS, the prompt mainloop immediately terminated.
286
                Returning None is equal to returning STATUS_PROGRESS.
287
        """
288
        return status
289
290
    def on_redraw(self):
291
        """Redraw the prompt.
292
293
        It is used to redraw the prompt. In default, it echos specified prefix
294
        the caret, and input text.
295
        """
296
        self.redraw_prompt()
297
298
    def on_keypress(self, keystroke):
299
        """Handle a pressed keystroke and return the status.
300
301
        It is used to handle a pressed keystroke. Note that subclass should NOT
302
        override this method to perform actions. Register a new custom action
303
        instead. In default, it call action and return the result if the
304
        keystroke is <xxx:xxx>or call Vim function XXX and return the result
305
        if the keystroke is <call:XXX>.
306
307
        Args:
308
            keystroke (Keystroke): A pressed keystroke instance. Note that this
309
                instance is a reslved keystroke instace by keymap.
310
311
        Returns:
312
            None or int: The return value will be used as a status of the
313
                prompt mainloop, indicating that if return value is not
314
                STATUS_PROGRESS, the prompt mainloop immediately terminated.
315
                Returning None is equal to returning STATUS_PROGRESS.
316
        """
317
        m = ACTION_KEYSTROKE_PATTERN.match(str(keystroke))
318
        if m:
319
            return self.action.call(self, m.group(1))
320
        else:
321
            self.update_text(str(keystroke))
322
323
    def on_term(self, status):
324
        """Finalize the prompt.
325
326
        It calls 'inputrestore' function in Vim to finalize the prompt in
327
        default. The return value is used as a return value of the prompt.
328
329
        Args:
330
            status (int): A prompt status.
331
332
        Returns:
333
            int: A status which is used as a result value of the prompt.
334
        """
335
        self.nvim.call('inputrestore')
336
        return status
337
338
339
def _build_echon_expr(hl, text):
340
    if not IMPRINTABLE_PATTERN.search(text):
341
        return 'echohl %s|echon "%s"' % (
342
            hl, text.translate(ESCAPE_ECHO)
343
        )
344
    p = 'echohl %s|echon "%%s"' % hl
345
    i = 'echohl %s|echon "%%s"' % ('SpecialKey' if hl == 'None' else hl)
346
    return '|'.join(
347
        p % term if index % 2 == 0 else i % IMPRINTABLE_REPRESENTS[term]
348
        for index, term in enumerate(IMPRINTABLE_PATTERN.split(text))
349
    )
350