1# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
2
3# Copyright 2014-2021 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
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"""Bridge to provide readline-like shortcuts for QLineEdits."""
21
22from typing import Iterable, Optional, MutableMapping
23
24from PyQt5.QtWidgets import QApplication, QLineEdit
25
26from qutebrowser.api import cmdutils
27
28
29class _ReadlineBridge:
30
31    """Bridge which provides readline-like commands for the current QLineEdit.
32
33    Attributes:
34        _deleted: Mapping from widgets to their last deleted text.
35    """
36
37    def __init__(self) -> None:
38        self._deleted: MutableMapping[QLineEdit, str] = {}
39
40    def _widget(self) -> Optional[QLineEdit]:
41        """Get the currently active QLineEdit."""
42        # FIXME add this to api.utils or so
43        qapp = QApplication.instance()
44        assert qapp is not None
45        w = qapp.focusWidget()
46
47        if isinstance(w, QLineEdit):
48            return w
49        else:
50            return None
51
52    def _dispatch(self, name: str, *,
53                  mark: bool = None,
54                  delete: bool = False) -> None:
55        widget = self._widget()
56        if widget is None:
57            return
58
59        method = getattr(widget, name)
60        if mark is None:
61            method()
62        else:
63            method(mark)
64
65        if delete:
66            self._deleted[widget] = widget.selectedText()
67            widget.del_()
68
69    def backward_char(self) -> None:
70        self._dispatch('cursorBackward', mark=False)
71
72    def forward_char(self) -> None:
73        self._dispatch('cursorForward', mark=False)
74
75    def backward_word(self) -> None:
76        self._dispatch('cursorWordBackward', mark=False)
77
78    def forward_word(self) -> None:
79        self._dispatch('cursorWordForward', mark=False)
80
81    def beginning_of_line(self) -> None:
82        self._dispatch('home', mark=False)
83
84    def end_of_line(self) -> None:
85        self._dispatch('end', mark=False)
86
87    def unix_line_discard(self) -> None:
88        self._dispatch('home', mark=True, delete=True)
89
90    def kill_line(self) -> None:
91        self._dispatch('end', mark=True, delete=True)
92
93    def _rubout(self, delim: Iterable[str]) -> None:
94        """Delete backwards using the characters in delim as boundaries."""
95        widget = self._widget()
96        if widget is None:
97            return
98        cursor_position = widget.cursorPosition()
99        text = widget.text()
100
101        target_position = cursor_position
102
103        is_boundary = True
104        while is_boundary and target_position > 0:
105            is_boundary = text[target_position - 1] in delim
106            target_position -= 1
107
108        is_boundary = False
109        while not is_boundary and target_position > 0:
110            is_boundary = text[target_position - 1] in delim
111            target_position -= 1
112
113        moveby = cursor_position - target_position - 1
114        widget.cursorBackward(True, moveby)
115        self._deleted[widget] = widget.selectedText()
116        widget.del_()
117
118    def unix_word_rubout(self) -> None:
119        self._rubout([' '])
120
121    def unix_filename_rubout(self) -> None:
122        self._rubout([' ', '/'])
123
124    def backward_kill_word(self) -> None:
125        self._dispatch('cursorWordBackward', mark=True, delete=True)
126
127    def kill_word(self) -> None:
128        self._dispatch('cursorWordForward', mark=True, delete=True)
129
130    def yank(self) -> None:
131        """Paste previously deleted text."""
132        widget = self._widget()
133        if widget is None or widget not in self._deleted:
134            return
135        widget.insert(self._deleted[widget])
136
137    def delete_char(self) -> None:
138        self._dispatch('del_')
139
140    def backward_delete_char(self) -> None:
141        self._dispatch('backspace')
142
143
144bridge = _ReadlineBridge()
145_register = cmdutils.register(
146    modes=[cmdutils.KeyMode.command, cmdutils.KeyMode.prompt])
147
148
149@_register
150def rl_backward_char() -> None:
151    """Move back a character.
152
153    This acts like readline's backward-char.
154    """
155    bridge.backward_char()
156
157
158@_register
159def rl_forward_char() -> None:
160    """Move forward a character.
161
162    This acts like readline's forward-char.
163    """
164    bridge.forward_char()
165
166
167@_register
168def rl_backward_word() -> None:
169    """Move back to the start of the current or previous word.
170
171    This acts like readline's backward-word.
172    """
173    bridge.backward_word()
174
175
176@_register
177def rl_forward_word() -> None:
178    """Move forward to the end of the next word.
179
180    This acts like readline's forward-word.
181    """
182    bridge.forward_word()
183
184
185@_register
186def rl_beginning_of_line() -> None:
187    """Move to the start of the line.
188
189    This acts like readline's beginning-of-line.
190    """
191    bridge.beginning_of_line()
192
193
194@_register
195def rl_end_of_line() -> None:
196    """Move to the end of the line.
197
198    This acts like readline's end-of-line.
199    """
200    bridge.end_of_line()
201
202
203@_register
204def rl_unix_line_discard() -> None:
205    """Remove chars backward from the cursor to the beginning of the line.
206
207    This acts like readline's unix-line-discard.
208    """
209    bridge.unix_line_discard()
210
211
212@_register
213def rl_kill_line() -> None:
214    """Remove chars from the cursor to the end of the line.
215
216    This acts like readline's kill-line.
217    """
218    bridge.kill_line()
219
220
221@_register
222def rl_unix_word_rubout() -> None:
223    """Remove chars from the cursor to the beginning of the word.
224
225    This acts like readline's unix-word-rubout. Whitespace is used as a
226    word delimiter.
227    """
228    bridge.unix_word_rubout()
229
230
231@_register
232def rl_unix_filename_rubout() -> None:
233    """Remove chars from the cursor to the previous path separator.
234
235    This acts like readline's unix-filename-rubout.
236    """
237    bridge.unix_filename_rubout()
238
239
240@_register
241def rl_backward_kill_word() -> None:
242    """Remove chars from the cursor to the beginning of the word.
243
244    This acts like readline's backward-kill-word. Any non-alphanumeric
245    character is considered a word delimiter.
246    """
247    bridge.backward_kill_word()
248
249
250@_register
251def rl_kill_word() -> None:
252    """Remove chars from the cursor to the end of the current word.
253
254    This acts like readline's kill-word.
255    """
256    bridge.kill_word()
257
258
259@_register
260def rl_yank() -> None:
261    """Paste the most recently deleted text.
262
263    This acts like readline's yank.
264    """
265    bridge.yank()
266
267
268@_register
269def rl_delete_char() -> None:
270    """Delete the character after the cursor.
271
272    This acts like readline's delete-char.
273    """
274    bridge.delete_char()
275
276
277@_register
278def rl_backward_delete_char() -> None:
279    """Delete the character before the cursor.
280
281    This acts like readline's backward-delete-char.
282    """
283    bridge.backward_delete_char()
284