anki/qt/aqt/webview.py

452 lines
14 KiB
Python
Raw Normal View History

2019-02-05 04:59:03 +01:00
# Copyright: Ankitects Pty Ltd and contributors
# -*- coding: utf-8 -*-
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import json
import math
2019-12-20 10:19:03 +01:00
import sys
from typing import Any, List, Optional, Tuple
2019-12-20 10:19:03 +01:00
from anki.lang import _
from anki.utils import isLin, isMac, isWin
from aqt import gui_hooks
from aqt.qt import *
from aqt.utils import openLink
# Page for debug messages
##########################################################################
2019-12-23 01:34:10 +01:00
class AnkiWebPage(QWebEnginePage): # type: ignore
def __init__(self, onBridgeCmd):
QWebEnginePage.__init__(self)
self._onBridgeCmd = onBridgeCmd
self._setupBridge()
def _setupBridge(self):
class Bridge(QObject):
@pyqtSlot(str, result=str)
def cmd(self, str):
return json.dumps(self.onCmd(str))
self._bridge = Bridge()
self._bridge.onCmd = self._onCmd
self._channel = QWebChannel(self)
self._channel.registerObject("py", self._bridge)
self.setWebChannel(self._channel)
2019-12-23 01:34:10 +01:00
js = QFile(":/qtwebchannel/qwebchannel.js")
assert js.open(QIODevice.ReadOnly)
2019-12-23 01:34:10 +01:00
js = bytes(js.readAll()).decode("utf-8")
script = QWebEngineScript()
2019-12-23 01:34:10 +01:00
script.setSourceCode(
js
+ """
var pycmd;
new QWebChannel(qt.webChannelTransport, function(channel) {
pycmd = function (arg, cb) {
var resultCB = function (res) {
// pass result back to user-provided callback
if (cb) {
cb(JSON.parse(res));
}
}
channel.objects.py.cmd(arg, resultCB);
return false;
}
pycmd("domDone");
});
2019-12-23 01:34:10 +01:00
"""
)
script.setWorldId(QWebEngineScript.MainWorld)
script.setInjectionPoint(QWebEngineScript.DocumentReady)
script.setRunsOnSubFrames(False)
self.profile().scripts().insert(script)
def javaScriptConsoleMessage(self, lvl, msg, line, srcID):
2017-08-06 05:10:51 +02:00
# not translated because console usually not visible,
# and may only accept ascii text
2019-12-23 01:34:10 +01:00
buf = "JS error on line %(a)d: %(b)s" % dict(a=line, b=msg + "\n")
# ensure we don't try to write characters the terminal can't handle
2019-12-23 01:34:10 +01:00
buf = buf.encode(sys.stdout.encoding, "backslashreplace").decode(
sys.stdout.encoding
)
sys.stdout.write(buf)
def acceptNavigationRequest(self, url, navType, isMainFrame):
if not isMainFrame:
return True
# data: links generated by setHtml()
if url.scheme() == "data":
return True
# catch buggy <a href='#' onclick='func()'> links
from aqt import mw
2019-12-23 01:34:10 +01:00
if url.matches(QUrl(mw.serverURL()), QUrl.RemoveFragment):
print("onclick handler needs to return false")
return False
# load all other links in browser
openLink(url)
return False
def _onCmd(self, str):
return self._onBridgeCmd(str)
2019-12-23 01:34:10 +01:00
# Main web view
##########################################################################
2019-12-23 01:34:10 +01:00
class AnkiWebView(QWebEngineView): # type: ignore
def __init__(self, parent: Optional[QWidget] = None) -> None:
QWebEngineView.__init__(self, parent=parent) # type: ignore
self.title = "default"
self._page = AnkiWebPage(self._onBridgeCmd)
self._page.setBackgroundColor(self._getWindowColor()) # reduce flicker
# in new code, use .set_bridge_command() instead of setting this directly
self.onBridgeCmd: Callable[[str], Any] = self.defaultOnBridgeCmd
self._domDone = True
self._pendingActions: List[Tuple[str, List[Any]]] = []
self.requiresCol = True
self.setPage(self._page)
self._page.profile().setHttpCacheType(QWebEngineProfile.NoCache) # type: ignore
self.resetHandlers()
self.allowDrops = False
self._filterSet = False
QShortcut( # type: ignore
2019-12-23 01:34:10 +01:00
QKeySequence("Esc"),
self,
context=Qt.WidgetWithChildrenShortcut,
activated=self.onEsc,
)
if isMac:
for key, fn in [
(QKeySequence.Copy, self.onCopy),
(QKeySequence.Paste, self.onPaste),
(QKeySequence.Cut, self.onCut),
(QKeySequence.SelectAll, self.onSelectAll),
]:
QShortcut( # type: ignore
2019-12-23 01:34:10 +01:00
key, self, context=Qt.WidgetWithChildrenShortcut, activated=fn
)
QShortcut( # type: ignore
2019-12-23 01:34:10 +01:00
QKeySequence("ctrl+shift+v"),
self,
context=Qt.WidgetWithChildrenShortcut,
activated=self.onPaste,
)
2017-06-23 06:34:56 +02:00
def eventFilter(self, obj, evt):
# disable pinch to zoom gesture
if isinstance(evt, QNativeGestureEvent):
return True
2018-03-02 02:16:02 +01:00
elif evt.type() == QEvent.MouseButtonRelease:
if evt.button() == Qt.MidButton and isLin:
self.onMiddleClickPaste()
return True
return False
2017-06-23 06:34:56 +02:00
return False
def onEsc(self):
w = self.parent()
while w:
if isinstance(w, QDialog) or isinstance(w, QMainWindow):
from aqt import mw
2019-12-23 01:34:10 +01:00
# esc in a child window closes the window
if w != mw:
w.close()
else:
# in the main window, removes focus from type in area
self.parent().setFocus()
break
w = w.parent()
def onCopy(self):
self.triggerPageAction(QWebEnginePage.Copy)
def onCut(self):
self.triggerPageAction(QWebEnginePage.Cut)
def onPaste(self):
self.triggerPageAction(QWebEnginePage.Paste)
2018-03-02 02:16:02 +01:00
def onMiddleClickPaste(self):
self.triggerPageAction(QWebEnginePage.Paste)
def onSelectAll(self):
self.triggerPageAction(QWebEnginePage.SelectAll)
def contextMenuEvent(self, evt) -> None:
m = QMenu(self)
a = m.addAction(_("Copy"))
a.triggered.connect(self.onCopy) # type: ignore
gui_hooks.webview_will_show_context_menu(self, m)
m.popup(QCursor.pos())
def dropEvent(self, evt):
pass
def setHtml(self, html):
# discard any previous pending actions
self._pendingActions = []
self._domDone = True
self._queueAction("setHtml", html)
def _setHtml(self, html):
app = QApplication.instance()
oldFocus = app.focusWidget()
self._domDone = False
self._page.setHtml(html)
# work around webengine stealing focus on setHtml()
if oldFocus:
oldFocus.setFocus()
2016-06-07 06:27:33 +02:00
def zoomFactor(self):
# overridden scale factor?
webscale = os.environ.get("ANKI_WEBSCALE")
if webscale:
return float(webscale)
if isMac:
return 1
2016-06-07 06:27:33 +02:00
screen = QApplication.desktop().screen()
dpi = screen.logicalDpiX()
factor = dpi / 96.0
if isLin:
factor = max(1, factor)
return factor
2019-12-17 09:43:32 +01:00
# compensate for qt's integer scaling on windows?
if qtminor >= 14:
return 1
qtIntScale = self._getQtIntScale(screen)
desiredScale = factor * qtIntScale
newFactor = desiredScale / qtIntScale
return max(1, newFactor)
def _getQtIntScale(self, screen):
# try to detect if Qt has scaled the screen
# - qt will round the scale factor to a whole number, so a dpi of 125% = 1x,
# and a dpi of 150% = 2x
# - a screen with a normal physical dpi of 72 will have a dpi of 32
# if the scale factor has been rounded to 2x
# - different screens have different physical DPIs (eg 72, 93, 102)
# - until a better solution presents itself, assume a physical DPI at
# or above 70 is unscaled
if screen.physicalDpiX() > 70:
return 1
elif screen.physicalDpiX() > 35:
return 2
else:
return 3
2016-06-07 06:27:33 +02:00
def _getWindowColor(self):
if isMac:
# standard palette does not return correct window color on macOS
return QColor("#ececec")
return self.style().standardPalette().color(QPalette.Window)
2019-03-04 07:01:10 +01:00
def stdHtml(self, body, css=None, js=None, head=""):
if css is None:
css = []
if js is None:
js = ["jquery.js"]
palette = self.style().standardPalette()
color_hl = palette.color(QPalette.Highlight).name()
2016-07-08 08:17:06 +02:00
if isWin:
2019-12-23 01:34:10 +01:00
# T: include a font for your language on Windows, eg: "Segoe UI", "MS Mincho"
family = _('"Segoe UI"')
widgetspec = "button { font-size: 12px; font-family:%s; }" % family
widgetspec += "\n:focus { outline: 1px solid %s; }" % color_hl
2019-12-23 01:34:10 +01:00
fontspec = "font-size:12px;font-family:%s;" % family
2016-07-08 08:17:06 +02:00
elif isMac:
2019-12-23 01:34:10 +01:00
family = "Helvetica"
fontspec = 'font-size:15px;font-family:"%s";' % family
widgetspec = """
2016-07-26 02:34:16 +02:00
button { font-size: 13px; -webkit-appearance: none; background: #fff; border: 1px solid #ccc;
border-radius:5px; font-family: Helvetica }"""
2016-07-08 08:17:06 +02:00
else:
family = self.font().family()
color_hl_txt = palette.color(QPalette.HighlightedText).name()
color_btn = palette.color(QPalette.Button).name()
2019-12-23 01:34:10 +01:00
fontspec = 'font-size:14px;font-family:"%s";' % family
widgetspec = """
/* Buttons */
button{ font-size:14px; -webkit-appearance:none; outline:0;
background-color: %(color_btn)s; border:1px solid rgba(0,0,0,.2);
border-radius:2px; height:24px; font-family:"%(family)s"; }
button:focus{ border-color: %(color_hl)s }
button:hover{ background-color:#fff }
button:active, button:active:hover { background-color: %(color_hl)s; color: %(color_hl_txt)s;}
/* Input field focus outline */
textarea:focus, input:focus, input[type]:focus, .uneditable-input:focus,
div[contenteditable="true"]:focus {
outline: 0 none;
border-color: %(color_hl)s;
2019-12-23 01:34:10 +01:00
}""" % {
"family": family,
"color_btn": color_btn,
"color_hl": color_hl,
"color_hl_txt": color_hl_txt,
}
csstxt = "\n".join(
[self.bundledCSS("webview.css")] + [self.bundledCSS(fname) for fname in css]
)
jstxt = "\n".join(
[self.bundledScript("webview.js")]
+ [self.bundledScript(fname) for fname in js]
)
from aqt import mw
2019-12-23 01:34:10 +01:00
head = mw.baseHTML() + head + csstxt + jstxt
html = """
<!doctype html>
<html><head>
<title>{}</title>
<style>
body {{ zoom: {}; background: {}; {} }}
{}
</style>
{}
</head>
<body>{}</body>
2019-12-23 01:34:10 +01:00
</html>""".format(
self.title,
self.zoomFactor(),
self._getWindowColor().name(),
fontspec,
widgetspec,
head,
body,
)
# print(html)
self.setHtml(html)
def webBundlePath(self, path):
from aqt import mw
2019-12-23 01:34:10 +01:00
return "http://127.0.0.1:%d/_anki/%s" % (mw.mediaServer.getPort(), path)
def bundledScript(self, fname):
return '<script src="%s"></script>' % self.webBundlePath(fname)
def bundledCSS(self, fname):
2019-12-23 01:34:10 +01:00
return '<link rel="stylesheet" type="text/css" href="%s">' % self.webBundlePath(
fname
)
def eval(self, js):
self.evalWithCallback(js, None)
2016-06-07 06:27:33 +02:00
def evalWithCallback(self, js, cb):
self._queueAction("eval", js, cb)
def _evalWithCallback(self, js, cb):
if cb:
2019-12-23 01:34:10 +01:00
def handler(val):
if self._shouldIgnoreWebEvent():
print("ignored late js callback", cb)
return
cb(val)
2019-12-23 01:34:10 +01:00
self.page().runJavaScript(js, handler)
else:
self.page().runJavaScript(js)
def _queueAction(self, name, *args):
self._pendingActions.append((name, args))
self._maybeRunActions()
def _maybeRunActions(self):
while self._pendingActions and self._domDone:
name, args = self._pendingActions.pop(0)
if name == "eval":
self._evalWithCallback(*args)
elif name == "setHtml":
self._setHtml(*args)
else:
raise Exception("unknown action: {}".format(name))
2016-06-07 06:27:33 +02:00
def _openLinksExternally(self, url):
openLink(url)
def _shouldIgnoreWebEvent(self):
# async web events may be received after the profile has been closed
# or the underlying webview has been deleted
from aqt import mw
2019-12-23 01:34:10 +01:00
if sip.isdeleted(self):
return True
if not mw.col and self.requiresCol:
return True
return False
def _onBridgeCmd(self, cmd: str) -> Any:
if self._shouldIgnoreWebEvent():
print("ignored late bridge cmd", cmd)
return
if not self._filterSet:
self.focusProxy().installEventFilter(self)
self._filterSet = True
if cmd == "domDone":
self._domDone = True
self._maybeRunActions()
else:
handled, result = gui_hooks.webview_did_receive_js_message(
(False, None), cmd, self._bridge_command_name
)
if handled:
return result
else:
return self.onBridgeCmd(cmd)
def defaultOnBridgeCmd(self, cmd: str) -> Any:
print("unhandled bridge cmd:", cmd)
# legacy
def resetHandlers(self):
self.onBridgeCmd = self.defaultOnBridgeCmd
self._bridge_command_name = "unknown"
def adjustHeightToFit(self):
self.evalWithCallback("$(document.body).height()", self._onHeight)
def _onHeight(self, qvar):
from aqt import mw
if qvar is None:
2019-12-23 01:34:10 +01:00
mw.progress.timer(1000, mw.reset, False)
return
scaleFactor = self.zoomFactor()
if scaleFactor == 1:
scaleFactor = mw.pm.uiScale()
height = math.ceil(qvar * scaleFactor)
self.setFixedHeight(height)
def set_bridge_command(self, func: Callable[[str], Any], context: str) -> None:
"""Set a handler for pycmd() messages received from Javascript.
Context is a human readable name that is provided to the
webview_did_receive_js_message hook."""
self.onBridgeCmd = func
self._bridge_command_name = context