1# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
2
3# Copyright 2015-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"""Base class for a QtWebKit/QtWebEngine web inspector."""
21
22import base64
23import binascii
24import enum
25from typing import cast, Optional
26
27from PyQt5.QtWidgets import QWidget
28from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QEvent
29from PyQt5.QtGui import QCloseEvent
30
31from qutebrowser.browser import eventfilter
32from qutebrowser.config import configfiles
33from qutebrowser.utils import log, usertypes
34from qutebrowser.keyinput import modeman
35from qutebrowser.misc import miscwidgets
36
37
38class Position(enum.Enum):
39
40    """Where the inspector is shown."""
41
42    right = enum.auto()
43    left = enum.auto()
44    top = enum.auto()
45    bottom = enum.auto()
46    window = enum.auto()
47
48
49class Error(Exception):
50
51    """Raised when the inspector could not be initialized."""
52
53
54class _EventFilter(QObject):
55
56    """Event filter to enter insert mode when inspector was clicked.
57
58    We need to use this with a ChildEventFilter (rather than just overriding
59    mousePressEvent) for two reasons:
60
61    - For QtWebEngine, we need to listen for mouse events on its focusProxy(),
62      which can change when another page loads (which might be possible with an
63      inspector as well?)
64
65    - For QtWebKit, we need to listen for mouse events on the QWebView used by
66      the QWebInspector.
67    """
68
69    clicked = pyqtSignal()
70
71    def eventFilter(self, _obj: QObject, event: QEvent) -> bool:
72        """Translate mouse presses to a clicked signal."""
73        if event.type() == QEvent.MouseButtonPress:
74            self.clicked.emit()
75        return False
76
77
78class AbstractWebInspector(QWidget):
79
80    """Base class for QtWebKit/QtWebEngine inspectors.
81
82    Attributes:
83        _position: position of the inspector (right/left/top/bottom/window)
84        _splitter: InspectorSplitter where the inspector can be placed.
85
86    Signals:
87        recreate: Emitted when the inspector should be recreated.
88    """
89
90    recreate = pyqtSignal()
91
92    def __init__(self, splitter: 'miscwidgets.InspectorSplitter',
93                 win_id: int,
94                 parent: QWidget = None) -> None:
95        super().__init__(parent)
96        self._widget = cast(QWidget, None)
97        self._layout = miscwidgets.WrapperLayout(self)
98        self._splitter = splitter
99        self._position: Optional[Position] = None
100        self._win_id = win_id
101
102        self._event_filter = _EventFilter(parent=self)
103        self._event_filter.clicked.connect(self._on_clicked)
104        self._child_event_filter = eventfilter.ChildEventFilter(
105            eventfilter=self._event_filter,
106            parent=self)
107
108    def _set_widget(self, widget: QWidget) -> None:
109        self._widget = widget
110        self._widget.setWindowTitle("Web Inspector")
111        self._widget.installEventFilter(self._child_event_filter)
112        self._layout.wrap(self, self._widget)
113
114    def _load_position(self) -> Position:
115        """Get the last position the inspector was in."""
116        pos = configfiles.state['inspector'].get('position', 'right')
117        return Position[pos]
118
119    def _save_position(self, position: Position) -> None:
120        """Save the last position the inspector was in."""
121        configfiles.state['inspector']['position'] = position.name
122
123    def _needs_recreate(self) -> bool:
124        """Whether the inspector needs recreation when detaching to a window.
125
126        This is done due to an unknown QtWebEngine bug which sometimes prevents
127        inspector windows from showing up.
128
129        Needs to be overridden by subclasses.
130        """
131        return False
132
133    @pyqtSlot()
134    def _on_clicked(self) -> None:
135        """Enter insert mode if a docked inspector was clicked."""
136        if self._position != Position.window:
137            modeman.enter(self._win_id, usertypes.KeyMode.insert,
138                          reason='Inspector clicked', only_if_normal=True)
139
140    def set_position(self, position: Optional[Position]) -> None:
141        """Set the position of the inspector.
142
143        If the position is None, the last known position is used.
144        """
145        if position is None:
146            position = self._load_position()
147        else:
148            self._save_position(position)
149
150        if position == self._position:
151            self.toggle()
152            return
153
154        if (position == Position.window and
155                self._position is not None and
156                self._needs_recreate()):
157            # Detaching to window
158            self.recreate.emit()
159            self.shutdown()
160            return
161        elif position == Position.window:
162            self.setParent(None)  # type: ignore[call-overload]
163            self._load_state_geometry()
164        else:
165            self._splitter.set_inspector(self, position)
166
167        self._position = position
168
169        self._widget.show()
170        self.show()
171
172    def toggle(self) -> None:
173        """Toggle visibility of the inspector."""
174        if self.isVisible():
175            self.hide()
176        else:
177            self.show()
178
179    def _load_state_geometry(self) -> None:
180        """Load the geometry from the state file."""
181        try:
182            data = configfiles.state['inspector']['window']
183            geom = base64.b64decode(data, validate=True)
184        except KeyError:
185            # First start
186            pass
187        except binascii.Error:
188            log.misc.exception("Error while reading geometry")
189        else:
190            log.init.debug("Loading geometry from {!r}".format(geom))
191            ok = self._widget.restoreGeometry(geom)
192            if not ok:
193                log.init.warning("Error while loading geometry.")
194
195    def closeEvent(self, _e: QCloseEvent) -> None:
196        """Save the geometry when closed."""
197        data = self._widget.saveGeometry().data()
198        geom = base64.b64encode(data).decode('ASCII')
199        configfiles.state['inspector']['window'] = geom
200
201    def inspect(self, page: QWidget) -> None:
202        """Inspect the given QWeb(Engine)Page."""
203        raise NotImplementedError
204
205    @pyqtSlot()
206    def shutdown(self) -> None:
207        """Clean up the inspector."""
208        self.close()
209        self.deleteLater()
210