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"""Custom useful data types."""
21
22import html
23import operator
24import enum
25import dataclasses
26from typing import Optional, Sequence, TypeVar, Union
27
28from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QTimer
29from PyQt5.QtCore import QUrl
30
31from qutebrowser.utils import log, qtutils, utils
32
33
34_T = TypeVar('_T', bound=utils.Comparable)
35
36
37class Unset:
38
39    """Class for an unset object."""
40
41    __slots__ = ()
42
43    def __repr__(self) -> str:
44        return '<UNSET>'
45
46
47UNSET = Unset()
48
49
50class NeighborList(Sequence[_T]):
51
52    """A list of items which saves its current position.
53
54    Class attributes:
55        Modes: Different modes, see constructor documentation.
56
57    Attributes:
58        fuzzyval: The value which is currently set but not in the list.
59        _idx: The current position in the list.
60        _items: A list of all items, accessed through item property.
61        _mode: The current mode.
62    """
63
64    class Modes(enum.Enum):
65
66        """Behavior for the 'mode' argument."""
67
68        edge = enum.auto()
69        exception = enum.auto()
70
71    def __init__(self, items: Sequence[_T] = None,
72                 default: Union[_T, Unset] = UNSET,
73                 mode: Modes = Modes.exception) -> None:
74        """Constructor.
75
76        Args:
77            items: The list of items to iterate in.
78            _default: The initially selected value.
79            _mode: Behavior when the first/last item is reached.
80                   Modes.edge: Go to the first/last item
81                   Modes.exception: Raise an IndexError.
82        """
83        if not isinstance(mode, self.Modes):
84            raise TypeError("Mode {} is not a Modes member!".format(mode))
85        if items is None:
86            self._items: Sequence[_T] = []
87        else:
88            self._items = list(items)
89        self._default = default
90
91        if not isinstance(default, Unset):
92            idx = self._items.index(default)
93            self._idx: Optional[int] = idx
94        else:
95            self._idx = None
96
97        self._mode = mode
98        self.fuzzyval: Optional[int] = None
99
100    def __getitem__(self, key: int) -> _T:  # type: ignore[override]
101        return self._items[key]
102
103    def __len__(self) -> int:
104        return len(self._items)
105
106    def __repr__(self) -> str:
107        return utils.get_repr(self, items=self._items, mode=self._mode,
108                              idx=self._idx, fuzzyval=self.fuzzyval)
109
110    def _snap_in(self, offset: int) -> bool:
111        """Set the current item to the closest item to self.fuzzyval.
112
113        Args:
114            offset: negative to get the next smaller item, positive for the
115                    next bigger one.
116
117        Return:
118            True if the value snapped in (changed),
119            False when the value already was in the list.
120        """
121        assert isinstance(self.fuzzyval, (int, float)), self.fuzzyval
122
123        op = operator.le if offset < 0 else operator.ge
124        items = [(idx, e) for (idx, e) in enumerate(self._items)
125                 if op(e, self.fuzzyval)]
126        if items:
127            item = min(
128                items,
129                key=lambda tpl:
130                abs(self.fuzzyval - tpl[1]))  # type: ignore[operator]
131        else:
132            sorted_items = sorted(enumerate(self.items), key=lambda e: e[1])
133            idx = 0 if offset < 0 else -1
134            item = sorted_items[idx]
135        self._idx = item[0]
136        return self.fuzzyval not in self._items
137
138    def _get_new_item(self, offset: int) -> _T:
139        """Logic for getitem to get the item at offset.
140
141        Args:
142            offset: The offset of the current item, relative to the last one.
143
144        Return:
145            The new item.
146        """
147        assert self._idx is not None
148        try:
149            if self._idx + offset >= 0:
150                new = self._items[self._idx + offset]
151            else:
152                raise IndexError
153        except IndexError:
154            if self._mode == self.Modes.edge:
155                assert offset != 0
156                if offset > 0:
157                    new = self.lastitem()
158                else:
159                    new = self.firstitem()
160            elif self._mode == self.Modes.exception:  # pragma: no branch
161                raise
162        else:
163            self._idx += offset
164        return new
165
166    @property
167    def items(self) -> Sequence[_T]:
168        """Getter for items, which should not be set."""
169        return self._items
170
171    def getitem(self, offset: int) -> _T:
172        """Get the item with a relative position.
173
174        Args:
175            offset: The offset of the current item, relative to the last one.
176
177        Return:
178            The new item.
179        """
180        log.misc.debug("{} items, idx {}, offset {}".format(
181            len(self._items), self._idx, offset))
182        if not self._items:
183            raise IndexError("No items found!")
184        if self.fuzzyval is not None:
185            # Value has been set to something not in the list, so we snap in to
186            # the closest value in the right direction and count this as one
187            # step towards offset.
188            snapped = self._snap_in(offset)
189            if snapped and offset > 0:
190                offset -= 1
191            elif snapped:
192                offset += 1
193            self.fuzzyval = None
194        return self._get_new_item(offset)
195
196    def curitem(self) -> _T:
197        """Get the current item in the list."""
198        if self._idx is not None:
199            return self._items[self._idx]
200        else:
201            raise IndexError("No current item!")
202
203    def nextitem(self) -> _T:
204        """Get the next item in the list."""
205        return self.getitem(1)
206
207    def previtem(self) -> _T:
208        """Get the previous item in the list."""
209        return self.getitem(-1)
210
211    def firstitem(self) -> _T:
212        """Get the first item in the list."""
213        if not self._items:
214            raise IndexError("No items found!")
215        self._idx = 0
216        return self.curitem()
217
218    def lastitem(self) -> _T:
219        """Get the last item in the list."""
220        if not self._items:
221            raise IndexError("No items found!")
222        self._idx = len(self._items) - 1
223        return self.curitem()
224
225    def reset(self) -> _T:
226        """Reset the position to the default."""
227        if self._default is UNSET:
228            raise ValueError("No default set!")
229        self._idx = self._items.index(self._default)
230        return self.curitem()
231
232
233class PromptMode(enum.Enum):
234
235    """The mode of a Question."""
236
237    yesno = enum.auto()
238    text = enum.auto()
239    user_pwd = enum.auto()
240    alert = enum.auto()
241    download = enum.auto()
242
243
244class ClickTarget(enum.Enum):
245
246    """How to open a clicked link."""
247
248    normal = enum.auto()  #: Open the link in the current tab
249    tab = enum.auto()  #: Open the link in a new foreground tab
250    tab_bg = enum.auto()  #: Open the link in a new background tab
251    window = enum.auto()  #: Open the link in a new window
252    hover = enum.auto()  #: Only hover over the link
253
254
255class KeyMode(enum.Enum):
256
257    """Key input modes."""
258
259    normal = enum.auto()  #: Normal mode (no mode was entered)
260    hint = enum.auto()  #: Hint mode (showing labels for links)
261    command = enum.auto()  #: Command mode (after pressing the colon key)
262    yesno = enum.auto()  #: Yes/No prompts
263    prompt = enum.auto()  #: Text prompts
264    insert = enum.auto()  #: Insert mode (passing through most keys)
265    passthrough = enum.auto()  #: Passthrough mode (passing through all keys)
266    caret = enum.auto()  #: Caret mode (moving cursor with keys)
267    set_mark = enum.auto()
268    jump_mark = enum.auto()
269    record_macro = enum.auto()
270    run_macro = enum.auto()
271    # 'register' is a bit of an oddball here: It's not really a "real" mode,
272    # but it's used in the config for common bindings for
273    # set_mark/jump_mark/record_macro/run_macro.
274    register = enum.auto()
275
276
277class Exit(enum.IntEnum):
278
279    """Exit statuses for errors. Needs to be an int for sys.exit."""
280
281    ok = 0
282    reserved = 1
283    exception = 2
284    err_ipc = 3
285    err_init = 4
286
287
288class LoadStatus(enum.Enum):
289
290    """Load status of a tab."""
291
292    none = enum.auto()
293    success = enum.auto()
294    success_https = enum.auto()
295    error = enum.auto()
296    warn = enum.auto()
297    loading = enum.auto()
298
299
300class Backend(enum.Enum):
301
302    """The backend being used (usertypes.backend)."""
303
304    QtWebKit = enum.auto()
305    QtWebEngine = enum.auto()
306
307
308class JsWorld(enum.Enum):
309
310    """World/context to run JavaScript code in."""
311
312    main = enum.auto()  #: Same world as the web page's JavaScript.
313    application = enum.auto()  #: Application world, used by qutebrowser internally.
314    user = enum.auto()  #: User world, currently not used.
315    jseval = enum.auto()  #: World used for the jseval-command.
316
317
318class JsLogLevel(enum.Enum):
319
320    """Log level of a JS message.
321
322    This needs to match up with the keys allowed for the
323    content.javascript.log setting.
324    """
325
326    unknown = enum.auto()
327    info = enum.auto()
328    warning = enum.auto()
329    error = enum.auto()
330
331
332class MessageLevel(enum.Enum):
333
334    """The level of a message being shown."""
335
336    error = enum.auto()
337    warning = enum.auto()
338    info = enum.auto()
339
340
341class IgnoreCase(enum.Enum):
342
343    """Possible values for the 'search.ignore_case' setting."""
344
345    smart = enum.auto()
346    never = enum.auto()
347    always = enum.auto()
348
349
350class CommandValue(enum.Enum):
351
352    """Special values which are injected when running a command handler."""
353
354    count = enum.auto()
355    win_id = enum.auto()
356    cur_tab = enum.auto()
357    count_tab = enum.auto()
358
359
360class Question(QObject):
361
362    """A question asked to the user, e.g. via the status bar.
363
364    Note the creator is responsible for cleaning up the question after it
365    doesn't need it anymore, e.g. via connecting Question.completed to
366    Question.deleteLater.
367
368    Attributes:
369        mode: A PromptMode enum member.
370              yesno: A question which can be answered with yes/no.
371              text: A question which requires a free text answer.
372              user_pwd: A question for a username and password.
373        default: The default value.
374                 For yesno, None (no default), True or False.
375                 For text, a default text as string.
376                 For user_pwd, a default username as string.
377        title: The question title to show.
378        text: The prompt text to display to the user.
379        url: Any URL referenced in prompts.
380        option: Boolean option to be set when answering always/never.
381        answer: The value the user entered (as password for user_pwd).
382        is_aborted: Whether the question was aborted.
383        interrupted: Whether the question was interrupted by another one.
384
385    Signals:
386        answered: Emitted when the question has been answered by the user.
387                  arg: The answer to the question.
388        cancelled: Emitted when the question has been cancelled by the user.
389        aborted: Emitted when the question was aborted programmatically.
390                 In this case, cancelled is not emitted.
391        answered_yes: Convenience signal emitted when a yesno question was
392                      answered with yes.
393        answered_no: Convenience signal emitted when a yesno question was
394                     answered with no.
395        completed: Emitted when the question was completed in any way.
396    """
397
398    answered = pyqtSignal(object)
399    cancelled = pyqtSignal()
400    aborted = pyqtSignal()
401    answered_yes = pyqtSignal()
402    answered_no = pyqtSignal()
403    completed = pyqtSignal()
404
405    def __init__(self, parent: QObject = None) -> None:
406        super().__init__(parent)
407        self.mode: Optional[PromptMode] = None
408        self.default: Union[bool, str, None] = None
409        self.title: Optional[str] = None
410        self.text: Optional[str] = None
411        self.url: Optional[str] = None
412        self.option: Optional[bool] = None
413        self.answer: Union[str, bool, None] = None
414        self.is_aborted = False
415        self.interrupted = False
416
417    def __repr__(self) -> str:
418        return utils.get_repr(self, title=self.title, text=self.text,
419                              mode=self.mode, default=self.default,
420                              option=self.option)
421
422    @pyqtSlot()
423    def done(self) -> None:
424        """Must be called when the question was answered completely."""
425        self.answered.emit(self.answer)
426        if self.mode == PromptMode.yesno:
427            if self.answer:
428                self.answered_yes.emit()
429            else:
430                self.answered_no.emit()
431        self.completed.emit()
432
433    @pyqtSlot()
434    def cancel(self) -> None:
435        """Cancel the question (resulting from user-input)."""
436        self.cancelled.emit()
437        self.completed.emit()
438
439    @pyqtSlot()
440    def abort(self) -> None:
441        """Abort the question."""
442        if self.is_aborted:
443            log.misc.debug("Question was already aborted")
444            return
445        self.is_aborted = True
446        self.aborted.emit()
447        self.completed.emit()
448
449
450class Timer(QTimer):
451
452    """A timer which has a name to show in __repr__ and checks for overflows.
453
454    Attributes:
455        _name: The name of the timer.
456    """
457
458    def __init__(self, parent: QObject = None, name: str = None) -> None:
459        super().__init__(parent)
460        if name is None:
461            self._name = "unnamed"
462        else:
463            self.setObjectName(name)
464            self._name = name
465
466    def __repr__(self) -> str:
467        return utils.get_repr(self, name=self._name)
468
469    def setInterval(self, msec: int) -> None:
470        """Extend setInterval to check for overflows."""
471        qtutils.check_overflow(msec, 'int')
472        super().setInterval(msec)
473
474    def start(self, msec: int = None) -> None:
475        """Extend start to check for overflows."""
476        if msec is not None:
477            qtutils.check_overflow(msec, 'int')
478            super().start(msec)
479        else:
480            super().start()
481
482
483class AbstractCertificateErrorWrapper:
484
485    """A wrapper over an SSL/certificate error."""
486
487    def __str__(self) -> str:
488        raise NotImplementedError
489
490    def __repr__(self) -> str:
491        raise NotImplementedError
492
493    def is_overridable(self) -> bool:
494        raise NotImplementedError
495
496    def html(self) -> str:
497        return f'<p>{html.escape(str(self))}</p>'
498
499
500@dataclasses.dataclass
501class NavigationRequest:
502
503    """A request to navigate to the given URL."""
504
505    class Type(enum.Enum):
506
507        """The type of a request.
508
509        Based on QWebEngineUrlRequestInfo::NavigationType and QWebPage::NavigationType.
510        """
511
512        #: Navigation initiated by clicking a link.
513        link_clicked = 1
514        #: Navigation explicitly initiated by typing a URL (QtWebEngine only).
515        typed = 2
516        #: Navigation submits a form.
517        form_submitted = 3
518        #: An HTML form was submitted a second time (QtWebKit only).
519        form_resubmitted = 4
520        #: Navigation initiated by a history action.
521        back_forward = 5
522        #: Navigation initiated by refreshing the page.
523        reloaded = 6
524        #: Navigation triggered automatically by page content or remote server
525        #: (QtWebEngine >= 5.14 only)
526        redirect = 7
527        #: None of the above.
528        other = 8
529
530    url: QUrl
531    navigation_type: Type
532    is_main_frame: bool
533    accepted: bool = True
534