1# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
2
3# Copyright 2016-2021 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net>
4#
5# This file is part of qutebrowser.
6#
7# qutebrowser is free software: you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation, either version 3 of the License, or
10# (at your option) any later version.
11#
12# qutebrowser is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with qutebrowser.  If not, see <https://www.gnu.org/licenses/>.
19
20"""Small window that pops up to show hints for possible keystrings.
21
22When a user inputs a key that forms a partial match, this shows a small window
23with each possible completion of that keystring and the corresponding command.
24It is intended to help discoverability of keybindings.
25"""
26
27import html
28import fnmatch
29import re
30
31from PyQt5.QtWidgets import QLabel, QSizePolicy
32from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt
33
34from qutebrowser.config import config, stylesheet
35from qutebrowser.utils import utils, usertypes
36from qutebrowser.misc import objects
37from qutebrowser.keyinput import keyutils
38
39
40class KeyHintView(QLabel):
41
42    """The view showing hints for key bindings based on the current key string.
43
44    Attributes:
45        _win_id: Window ID of parent.
46
47    Signals:
48        update_geometry: Emitted when this widget should be resized/positioned.
49    """
50
51    STYLESHEET = """
52        QLabel {
53            font: {{ conf.fonts.keyhint }};
54            color: {{ conf.colors.keyhint.fg }};
55            background-color: {{ conf.colors.keyhint.bg }};
56            padding: 6px;
57            {% if conf.statusbar.position == 'top' %}
58                border-bottom-right-radius: {{ conf.keyhint.radius }}px;
59            {% else %}
60                border-top-right-radius: {{ conf.keyhint.radius }}px;
61            {% endif %}
62        }
63    """
64    update_geometry = pyqtSignal()
65
66    def __init__(self, win_id, parent=None):
67        super().__init__(parent)
68        self.setTextFormat(Qt.RichText)
69        self._win_id = win_id
70        self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Minimum)
71        self.hide()
72        self._show_timer = usertypes.Timer(self, 'keyhint_show')
73        self._show_timer.timeout.connect(self.show)
74        self._show_timer.setSingleShot(True)
75        stylesheet.set_register(self)
76
77    def __repr__(self):
78        return utils.get_repr(self, win_id=self._win_id)
79
80    def showEvent(self, e):
81        """Adjust the keyhint size when it's freshly shown."""
82        self.update_geometry.emit()
83        super().showEvent(e)
84
85    @pyqtSlot(usertypes.KeyMode, str)
86    def update_keyhint(self, mode, prefix):
87        """Show hints for the given prefix (or hide if prefix is empty).
88
89        Args:
90            prefix: The current partial keystring.
91        """
92        match = re.fullmatch(r'(\d*)(.*)', prefix)
93        assert match is not None, prefix
94
95        countstr, prefix = match.groups()
96        if not prefix:
97            self._show_timer.stop()
98            self.hide()
99            return
100
101        def blacklisted(keychain):
102            return any(fnmatch.fnmatchcase(keychain, glob)
103                       for glob in config.val.keyhint.blacklist)
104
105        def takes_count(cmdstr):
106            """Return true iff this command can take a count argument."""
107            cmdname = cmdstr.split(' ')[0]
108            cmd = objects.commands.get(cmdname)
109            return cmd and cmd.takes_count()
110
111        bindings_dict = config.key_instance.get_bindings_for(mode.name)
112        bindings = [(k, v) for (k, v) in sorted(bindings_dict.items())
113                    if keyutils.KeySequence.parse(prefix).matches(k) and
114                    not blacklisted(str(k)) and
115                    (takes_count(v) or not countstr)]
116
117        if not bindings:
118            self._show_timer.stop()
119            return
120
121        # delay so a quickly typed keychain doesn't display hints
122        self._show_timer.setInterval(config.val.keyhint.delay)
123        self._show_timer.start()
124        suffix_color = html.escape(config.val.colors.keyhint.suffix.fg)
125
126        text = ''
127        for seq, cmd in bindings:
128            text += (
129                "<tr>"
130                "<td>{}</td>"
131                "<td style='color: {}'>{}</td>"
132                "<td style='padding-left: 2ex'>{}</td>"
133                "</tr>"
134            ).format(
135                html.escape(prefix),
136                suffix_color,
137                html.escape(str(seq)[len(prefix):]),
138                html.escape(cmd)
139            )
140        text = '<table>{}</table>'.format(text)
141
142        self.setText(text)
143        self.adjustSize()
144        self.update_geometry.emit()
145