summaryrefslogtreecommitdiff
path: root/qutebrowser/keyinput/modeparsers.py
blob: 6ebbc73a64f56bca54fb2ef60fb46ccf9e7424d6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:

# Copyright 2014-2021 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser.  If not, see <https://www.gnu.org/licenses/>.

"""KeyChainParser for "hint" and "normal" modes.

Module attributes:
    STARTCHARS: Possible chars for starting a commandline input.
"""

import enum
import traceback
from typing import TYPE_CHECKING, Sequence

from qutebrowser.commands import cmdexc
from qutebrowser.config import config
from qutebrowser.keyinput import basekeyparser, keyutils, macros
from qutebrowser.qt import QtCore, QtGui
from qutebrowser.utils import log, message, objreg, usertypes, utils

if TYPE_CHECKING:
    from qutebrowser.browser import hints
    from qutebrowser.commands import runners


STARTCHARS = ":/?"


class LastPress(enum.Enum):

    """Whether the last keypress filtered a text or was part of a keystring."""

    none = enum.auto()
    filtertext = enum.auto()
    keystring = enum.auto()


class CommandKeyParser(basekeyparser.BaseKeyParser):

    """KeyChainParser for command bindings.

    Attributes:
        _commandrunner: CommandRunner instance.
    """

    def __init__(
        self,
        *,
        mode: usertypes.KeyMode,
        win_id: int,
        commandrunner: 'runners.CommandRunner',
        parent: QtCore.QObject = None,
        do_log: bool = True,
        passthrough: bool = False,
        supports_count: bool = True
    ) -> None:
        super().__init__(
            mode=mode,
            win_id=win_id,
            parent=parent,
            do_log=do_log,
            passthrough=passthrough,
            supports_count=supports_count,
        )
        self._commandrunner = commandrunner

    def execute(self, cmdstr: str, count: int = None) -> None:
        try:
            self._commandrunner.run(cmdstr, count)
        except cmdexc.Error as e:
            message.error(str(e), stack=traceback.format_exc())


class NormalKeyParser(CommandKeyParser):

    """KeyParser for normal mode with added STARTCHARS detection and more.

    Attributes:
        _partial_timer: Timer to clear partial keypresses.
    """

    _sequence: keyutils.KeySequence

    def __init__(
        self,
        *,
        win_id: int,
        commandrunner: 'runners.CommandRunner',
        parent: QtCore.QObject = None
    ) -> None:
        super().__init__(
            mode=usertypes.KeyMode.normal,
            win_id=win_id,
            commandrunner=commandrunner,
            parent=parent,
        )
        self._partial_timer = usertypes.Timer(self, 'partial-match')
        self._partial_timer.setSingleShot(True)
        self._partial_timer.timeout.connect(self._clear_partial_match)
        self._inhibited = False
        self._inhibited_timer = usertypes.Timer(self, 'normal-inhibited')
        self._inhibited_timer.setSingleShot(True)

    def __repr__(self) -> str:
        return utils.get_repr(self)

    def handle(
        self, e: QtGui.QKeyEvent, *, dry_run: bool = False
    ) -> QtGui.QKeySequence.SequenceMatch:
        """Override to abort if the key is a startchar."""
        txt = e.text().strip()
        if self._inhibited:
            self._debug_log("Ignoring key '{}', because the normal mode is "
                            "currently inhibited.".format(txt))
            return QtGui.QKeySequence.NoMatch

        match = super().handle(e, dry_run=dry_run)

        if match == QtGui.QKeySequence.PartialMatch and not dry_run:
            timeout = config.val.input.partial_timeout
            if timeout != 0:
                self._partial_timer.setInterval(timeout)
                self._partial_timer.start()
        return match

    def set_inhibited_timeout(self, timeout: int) -> None:
        """Ignore keypresses for the given duration."""
        if timeout != 0:
            self._debug_log("Inhibiting the normal mode for {}ms.".format(
                timeout))
            self._inhibited = True
            self._inhibited_timer.setInterval(timeout)
            self._inhibited_timer.timeout.connect(self._clear_inhibited)
            self._inhibited_timer.start()

    @QtCore.pyqtSlot()
    def _clear_partial_match(self) -> None:
        """Clear a partial keystring after a timeout."""
        self._debug_log("Clearing partial keystring {}".format(
            self._sequence))
        self._sequence = keyutils.KeySequence()
        self.keystring_updated.emit(str(self._sequence))

    @QtCore.pyqtSlot()
    def _clear_inhibited(self) -> None:
        """Reset inhibition state after a timeout."""
        self._debug_log("Releasing inhibition state of normal mode.")
        self._inhibited = False


class HintKeyParser(basekeyparser.BaseKeyParser):

    """KeyChainParser for hints.

    Attributes:
        _filtertext: The text to filter with.
        _hintmanager: The HintManager to use.
        _last_press: The nature of the last keypress, a LastPress member.
    """

    _sequence: keyutils.KeySequence

    def __init__(
        self,
        *,
        win_id: int,
        commandrunner: 'runners.CommandRunner',
        hintmanager: 'hints.HintManager',
        parent: QtCore.QObject = None
    ) -> None:
        super().__init__(
            mode=usertypes.KeyMode.hint,
            win_id=win_id,
            parent=parent,
            supports_count=False,
        )
        self._command_parser = CommandKeyParser(
            mode=usertypes.KeyMode.hint,
            win_id=win_id,
            commandrunner=commandrunner,
            parent=self,
            supports_count=False,
        )
        self._hintmanager = hintmanager
        self._filtertext = ''
        self._last_press = LastPress.none
        self.keystring_updated.connect(self._hintmanager.handle_partial_key)

    def _handle_filter_key(
        self, e: QtGui.QKeyEvent
    ) -> QtGui.QKeySequence.SequenceMatch:
        """Handle keys for string filtering."""
        log.keyboard.debug("Got filter key 0x{:x} text {}".format(
            e.key(), e.text()))
        if e.key() == QtCore.Qt.Key_Backspace:
            log.keyboard.debug("Got backspace, mode {}, filtertext '{}', "
                               "sequence '{}'".format(self._last_press,
                                                      self._filtertext,
                                                      self._sequence))
            if self._last_press != LastPress.keystring and self._filtertext:
                self._filtertext = self._filtertext[:-1]
                self._hintmanager.filter_hints(self._filtertext)
                return QtGui.QKeySequence.ExactMatch
            elif self._last_press == LastPress.keystring and self._sequence:
                self._sequence = self._sequence[:-1]
                self.keystring_updated.emit(str(self._sequence))
                if not self._sequence and self._filtertext:
                    # Switch back to hint filtering mode (this can happen only
                    # in numeric mode after the number has been deleted).
                    self._hintmanager.filter_hints(self._filtertext)
                    self._last_press = LastPress.filtertext
                return QtGui.QKeySequence.ExactMatch
            else:
                return QtGui.QKeySequence.NoMatch
        elif self._hintmanager.current_mode() != 'number':
            return QtGui.QKeySequence.NoMatch
        elif not e.text():
            return QtGui.QKeySequence.NoMatch
        else:
            self._filtertext += e.text()
            self._hintmanager.filter_hints(self._filtertext)
            self._last_press = LastPress.filtertext
            return QtGui.QKeySequence.ExactMatch

    def handle(
        self, e: QtGui.QKeyEvent, *, dry_run: bool = False
    ) -> QtGui.QKeySequence.SequenceMatch:
        """Handle a new keypress and call the respective handlers."""
        if dry_run:
            return super().handle(e, dry_run=True)

        assert not dry_run

        if self._command_parser.handle(e, dry_run=True) != QtGui.QKeySequence.NoMatch:
            log.keyboard.debug("Handling key via command parser")
            self.clear_keystring()
            return self._command_parser.handle(e)

        match = super().handle(e)

        if match == QtGui.QKeySequence.PartialMatch:
            self._last_press = LastPress.keystring
        elif match == QtGui.QKeySequence.ExactMatch:
            self._last_press = LastPress.none
        elif match == QtGui.QKeySequence.NoMatch:
            # We couldn't find a keychain so we check if it's a special key.
            return self._handle_filter_key(e)
        else:
            raise ValueError("Got invalid match type {}!".format(match))

        return match

    def update_bindings(self, strings: Sequence[str],
                        preserve_filter: bool = False) -> None:
        """Update bindings when the hint strings changed.

        Args:
            strings: A list of hint strings.
            preserve_filter: Whether to keep the current value of
                             `self._filtertext`.
        """
        self._read_config()
        self.bindings.update({keyutils.KeySequence.parse(s): s
                              for s in strings})
        if not preserve_filter:
            self._filtertext = ''

    def execute(self, cmdstr: str, count: int = None) -> None:
        assert count is None
        self._hintmanager.handle_partial_key(cmdstr)


class RegisterKeyParser(CommandKeyParser):

    """KeyParser for modes that record a register key.

    Attributes:
        _register_mode: One of KeyMode.set_mark, KeyMode.jump_mark,
                        KeyMode.record_macro and KeyMode.run_macro.
    """

    def __init__(
        self,
        *,
        win_id: int,
        mode: usertypes.KeyMode,
        commandrunner: 'runners.CommandRunner',
        parent: QtCore.QObject = None
    ) -> None:
        super().__init__(
            mode=usertypes.KeyMode.register,
            win_id=win_id,
            commandrunner=commandrunner,
            parent=parent,
            supports_count=False,
        )
        self._register_mode = mode

    def handle(
        self, e: QtGui.QKeyEvent, *, dry_run: bool = False
    ) -> QtGui.QKeySequence.SequenceMatch:
        """Override to always match the next key and use the register."""
        match = super().handle(e, dry_run=dry_run)
        if match or dry_run:
            return match

        if keyutils.is_special(QtCore.Qt.Key(e.key()), e.modifiers()):
            # this is not a proper register key, let it pass and keep going
            return QtGui.QKeySequence.NoMatch

        key = e.text()

        tabbed_browser = objreg.get('tabbed-browser', scope='window',
                                    window=self._win_id)

        try:
            if self._register_mode == usertypes.KeyMode.set_mark:
                tabbed_browser.set_mark(key)
            elif self._register_mode == usertypes.KeyMode.jump_mark:
                tabbed_browser.jump_mark(key)
            elif self._register_mode == usertypes.KeyMode.record_macro:
                macros.macro_recorder.record_macro(key)
            elif self._register_mode == usertypes.KeyMode.run_macro:
                macros.macro_recorder.run_macro(self._win_id, key)
            else:
                raise ValueError("{} is not a valid register mode".format(
                    self._register_mode))
        except cmdexc.Error as err:
            message.error(str(err), stack=traceback.format_exc())

        self.request_leave.emit(
            self._register_mode, "valid register key", True)
        return QtGui.QKeySequence.ExactMatch