1# Copyright: Ankitects Pty Ltd and contributors
2# -*- coding: utf-8 -*-
3# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
4import json
5import sys
6import math
7from anki.hooks import runHook
8from aqt.qt import *
9from aqt.utils import openLink
10from anki.utils import isMac, isWin, isLin
11from anki.lang import _
12
13# Page for debug messages
14##########################################################################
15
16class AnkiWebPage(QWebEnginePage):
17
18    def __init__(self, onBridgeCmd):
19        QWebEnginePage.__init__(self)
20        self._onBridgeCmd = onBridgeCmd
21        self._setupBridge()
22
23    def _setupBridge(self):
24        class Bridge(QObject):
25            @pyqtSlot(str, result=str)
26            def cmd(self, str):
27                return json.dumps(self.onCmd(str))
28
29        self._bridge = Bridge()
30        self._bridge.onCmd = self._onCmd
31
32        self._channel = QWebChannel(self)
33        self._channel.registerObject("py", self._bridge)
34        self.setWebChannel(self._channel)
35
36        js = QFile(':/qtwebchannel/qwebchannel.js')
37        assert js.open(QIODevice.ReadOnly)
38        js = bytes(js.readAll()).decode('utf-8')
39
40        script = QWebEngineScript()
41        script.setSourceCode(js + '''
42            var pycmd;
43            new QWebChannel(qt.webChannelTransport, function(channel) {
44                pycmd = function (arg, cb) {
45                    var resultCB = function (res) {
46                        // pass result back to user-provided callback
47                        if (cb) {
48                            cb(JSON.parse(res));
49                        }
50                    }
51
52                    channel.objects.py.cmd(arg, resultCB);
53                    return false;
54                }
55                pycmd("domDone");
56            });
57        ''')
58        script.setWorldId(QWebEngineScript.MainWorld)
59        script.setInjectionPoint(QWebEngineScript.DocumentReady)
60        script.setRunsOnSubFrames(False)
61        self.profile().scripts().insert(script)
62
63    def javaScriptConsoleMessage(self, lvl, msg, line, srcID):
64        # not translated because console usually not visible,
65        # and may only accept ascii text
66        buf = "JS error on line %(a)d: %(b)s" % dict(a=line, b=msg+"\n")
67        # ensure we don't try to write characters the terminal can't handle
68        buf = buf.encode(sys.stdout.encoding, "backslashreplace").decode(sys.stdout.encoding)
69        sys.stdout.write(buf)
70
71    def acceptNavigationRequest(self, url, navType, isMainFrame):
72        if not isMainFrame:
73            return True
74        # data: links generated by setHtml()
75        if url.scheme() == "data":
76            return True
77        # catch buggy <a href='#' onclick='func()'> links
78        from aqt import mw
79        if url.matches(QUrl(mw.serverURL()), QUrl.RemoveFragment):
80            print("onclick handler needs to return false")
81            return False
82        # load all other links in browser
83        openLink(url)
84        return False
85
86    def _onCmd(self, str):
87        return self._onBridgeCmd(str)
88
89# Main web view
90##########################################################################
91
92class AnkiWebView(QWebEngineView):
93
94    def __init__(self, parent=None):
95        QWebEngineView.__init__(self, parent=parent)
96        self.title = "default"
97        self._page = AnkiWebPage(self._onBridgeCmd)
98        self._page.setBackgroundColor(self._getWindowColor())  # reduce flicker
99
100        self._domDone = True
101        self._pendingActions = []
102        self.requiresCol = True
103        self.setPage(self._page)
104
105        self._page.profile().setHttpCacheType(QWebEngineProfile.NoCache)
106        self.resetHandlers()
107        self.allowDrops = False
108        self._filterSet = False
109        QShortcut(QKeySequence("Esc"), self,
110                  context=Qt.WidgetWithChildrenShortcut, activated=self.onEsc)
111        if isMac:
112            for key, fn in [
113                (QKeySequence.Copy, self.onCopy),
114                (QKeySequence.Paste, self.onPaste),
115                (QKeySequence.Cut, self.onCut),
116                (QKeySequence.SelectAll, self.onSelectAll),
117            ]:
118                QShortcut(key, self,
119                          context=Qt.WidgetWithChildrenShortcut,
120                          activated=fn)
121            QShortcut(QKeySequence("ctrl+shift+v"), self,
122                      context=Qt.WidgetWithChildrenShortcut, activated=self.onPaste)
123
124    def eventFilter(self, obj, evt):
125        # disable pinch to zoom gesture
126        if isinstance(evt, QNativeGestureEvent):
127            return True
128        elif evt.type() == QEvent.MouseButtonRelease:
129            if evt.button() == Qt.MidButton and isLin:
130                self.onMiddleClickPaste()
131                return True
132            return False
133        return False
134
135    def onEsc(self):
136        w = self.parent()
137        while w:
138            if isinstance(w, QDialog) or isinstance(w, QMainWindow):
139                from aqt import mw
140                # esc in a child window closes the window
141                if w != mw:
142                    w.close()
143                else:
144                    # in the main window, removes focus from type in area
145                    self.parent().setFocus()
146                break
147            w = w.parent()
148
149    def onCopy(self):
150        self.triggerPageAction(QWebEnginePage.Copy)
151
152    def onCut(self):
153        self.triggerPageAction(QWebEnginePage.Cut)
154
155    def onPaste(self):
156        self.triggerPageAction(QWebEnginePage.Paste)
157
158    def onMiddleClickPaste(self):
159        self.triggerPageAction(QWebEnginePage.Paste)
160
161    def onSelectAll(self):
162        self.triggerPageAction(QWebEnginePage.SelectAll)
163
164    def contextMenuEvent(self, evt):
165        m = QMenu(self)
166        a = m.addAction(_("Copy"))
167        a.triggered.connect(self.onCopy)
168        runHook("AnkiWebView.contextMenuEvent", self, m)
169        m.popup(QCursor.pos())
170
171    def dropEvent(self, evt):
172        pass
173
174    def setHtml(self, html):
175        # discard any previous pending actions
176        self._pendingActions = []
177        self._domDone = True
178        self._queueAction("setHtml", html)
179
180    def _setHtml(self, html):
181        app = QApplication.instance()
182        oldFocus = app.focusWidget()
183        self._domDone = False
184        self._page.setHtml(html)
185        # work around webengine stealing focus on setHtml()
186        if oldFocus:
187            oldFocus.setFocus()
188
189    def zoomFactor(self):
190        # overridden scale factor?
191        webscale = os.environ.get("ANKI_WEBSCALE")
192        if webscale:
193            return float(webscale)
194
195        if isMac:
196            return 1
197        screen = QApplication.desktop().screen()
198        dpi = screen.logicalDpiX()
199        factor = dpi / 96.0
200        if isLin:
201            factor = max(1, factor)
202            return factor
203        # compensate for qt's integer scaling on windows
204        qtIntScale = self._getQtIntScale(screen)
205        desiredScale = factor * qtIntScale
206        newFactor = desiredScale / qtIntScale
207        return max(1, newFactor)
208
209    def _getQtIntScale(self, screen):
210        # try to detect if Qt has scaled the screen
211        # - qt will round the scale factor to a whole number, so a dpi of 125% = 1x,
212        #   and a dpi of 150% = 2x
213        # - a screen with a normal physical dpi of 72 will have a dpi of 32
214        #   if the scale factor has been rounded to 2x
215        # - different screens have different physical DPIs (eg 72, 93, 102)
216        # - until a better solution presents itself, assume a physical DPI at
217        #   or above 70 is unscaled
218        if screen.physicalDpiX() > 70:
219            return 1
220        elif screen.physicalDpiX() > 35:
221            return 2
222        else:
223            return 3
224
225    def _getWindowColor(self):
226        if isMac:
227            # standard palette does not return correct window color on macOS
228            return QColor("#ececec")
229        return self.style().standardPalette().color(QPalette.Window)
230
231    def stdHtml(self, body, css=None, js=None, head=""):
232        if css is None:
233            css = []
234        if js is None:
235            js = ["jquery.js"]
236
237        palette = self.style().standardPalette()
238        color_hl = palette.color(QPalette.Highlight).name()
239
240        if isWin:
241            #T: include a font for your language on Windows, eg: "Segoe UI", "MS Mincho"
242            family = _('"Segoe UI"')
243            widgetspec = "button { font-size: 12px; font-family:%s; }" % family
244            widgetspec += "\n:focus { outline: 1px solid %s; }" % color_hl
245            fontspec = 'font-size:12px;font-family:%s;' % family
246        elif isMac:
247            family="Helvetica"
248            fontspec = 'font-size:15px;font-family:"%s";'% \
249                       family
250            widgetspec = """
251button { font-size: 13px; -webkit-appearance: none; background: #fff; border: 1px solid #ccc;
252border-radius:5px; font-family: Helvetica }"""
253        else:
254            family = self.font().family()
255            color_hl_txt = palette.color(QPalette.HighlightedText).name()
256            color_btn = palette.color(QPalette.Button).name()
257            fontspec = 'font-size:14px;font-family:"%s";'% family
258            widgetspec = """
259/* Buttons */
260button{ font-size:14px; -webkit-appearance:none; outline:0;
261        background-color: %(color_btn)s; border:1px solid rgba(0,0,0,.2);
262        border-radius:2px; height:24px; font-family:"%(family)s"; }
263button:focus{ border-color: %(color_hl)s }
264button:hover{ background-color:#fff }
265button:active, button:active:hover { background-color: %(color_hl)s; color: %(color_hl_txt)s;}
266/* Input field focus outline */
267textarea:focus, input:focus, input[type]:focus, .uneditable-input:focus,
268div[contenteditable="true"]:focus {
269    outline: 0 none;
270    border-color: %(color_hl)s;
271}""" % {"family": family, "color_btn": color_btn,
272        "color_hl": color_hl, "color_hl_txt": color_hl_txt}
273
274        csstxt = "\n".join([self.bundledCSS("webview.css")]+
275                           [self.bundledCSS(fname) for fname in css])
276        jstxt = "\n".join([self.bundledScript("webview.js")]+
277                          [self.bundledScript(fname) for fname in js])
278        from aqt import mw
279        head =  mw.baseHTML() + head + csstxt + jstxt
280
281        html = """
282<!doctype html>
283<html><head>
284<title>{}</title>
285
286<style>
287body {{ zoom: {}; background: {}; {} }}
288{}
289</style>
290
291{}
292</head>
293
294<body>{}</body>
295</html>""".format(self.title, self.zoomFactor(), self._getWindowColor().name(),
296                  fontspec, widgetspec, head, body)
297        #print(html)
298        self.setHtml(html)
299
300    def webBundlePath(self, path):
301        from aqt import mw
302        return "http://127.0.0.1:%d/_anki/%s" % (mw.mediaServer.getPort(), path)
303
304    def bundledScript(self, fname):
305        return '<script src="%s"></script>' % self.webBundlePath(fname)
306
307    def bundledCSS(self, fname):
308        return '<link rel="stylesheet" type="text/css" href="%s">' % self.webBundlePath(fname)
309
310    def eval(self, js):
311        self.evalWithCallback(js, None)
312
313    def evalWithCallback(self, js, cb):
314        self._queueAction("eval", js, cb)
315
316    def _evalWithCallback(self, js, cb):
317        if cb:
318            def handler(val):
319                if self._shouldIgnoreWebEvent():
320                    print("ignored late js callback", cb)
321                    return
322                cb(val)
323            self.page().runJavaScript(js, handler)
324        else:
325            self.page().runJavaScript(js)
326
327    def _queueAction(self, name, *args):
328        self._pendingActions.append((name, args))
329        self._maybeRunActions()
330
331    def _maybeRunActions(self):
332        while self._pendingActions and self._domDone:
333            name, args = self._pendingActions.pop(0)
334
335            if name == "eval":
336                self._evalWithCallback(*args)
337            elif name == "setHtml":
338                self._setHtml(*args)
339            else:
340                raise Exception("unknown action: {}".format(name))
341
342    def _openLinksExternally(self, url):
343        openLink(url)
344
345    def _shouldIgnoreWebEvent(self):
346        # async web events may be received after the profile has been closed
347        # or the underlying webview has been deleted
348        from aqt import mw
349        if sip.isdeleted(self):
350            return True
351        if not mw.col and self.requiresCol:
352            return True
353        return False
354
355    def _onBridgeCmd(self, cmd):
356        if self._shouldIgnoreWebEvent():
357            print("ignored late bridge cmd", cmd)
358            return
359
360        if not self._filterSet:
361            self.focusProxy().installEventFilter(self)
362            self._filterSet = True
363
364        if cmd == "domDone":
365            self._domDone = True
366            self._maybeRunActions()
367        else:
368            return self.onBridgeCmd(cmd)
369
370    def defaultOnBridgeCmd(self, cmd):
371        print("unhandled bridge cmd:", cmd)
372
373    def resetHandlers(self):
374        self.onBridgeCmd = self.defaultOnBridgeCmd
375
376    def adjustHeightToFit(self):
377        self.evalWithCallback("$(document.body).height()", self._onHeight)
378
379    def _onHeight(self, qvar):
380        if qvar is None:
381            from aqt import mw
382            mw.progress.timer(1000, mw.reset, False)
383            return
384
385        height = math.ceil(qvar*self.zoomFactor())
386        self.setFixedHeight(height)
387