anki/aqt/webview.py
Damien Elmes c74cbf6108 fix media server port allocation
the old code was allowing the main thread to read .port before it had
been updated, and was binding to sockets that were already in use on
Windows. instead, we use a system-assigned free port and block the main
thread until it's been allocated
2017-08-08 14:56:34 +10:00

262 lines
8.0 KiB
Python

# Copyright: Damien Elmes <anki@ichi2.net>
# -*- coding: utf-8 -*-
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import sys
from anki.hooks import runHook
from aqt.qt import *
from aqt.utils import openLink
from anki.utils import isMac, isWin, devMode
# Page for debug messages
##########################################################################
class AnkiWebPage(QWebEnginePage):
def __init__(self, onBridgeCmd):
QWebEnginePage.__init__(self)
self._onBridgeCmd = onBridgeCmd
self._setupBridge()
self.setBackgroundColor(Qt.transparent)
def _setupBridge(self):
class Bridge(QObject):
@pyqtSlot(str)
def cmd(self, str):
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)
js = QFile(':/qtwebchannel/qwebchannel.js')
assert js.open(QIODevice.ReadOnly)
js = bytes(js.readAll()).decode('utf-8')
script = QWebEngineScript()
script.setSourceCode(js + '''
var pycmd;
new QWebChannel(qt.webChannelTransport, function(channel) {
pycmd = channel.objects.py.cmd;
pycmd("domDone");
});
''')
script.setWorldId(QWebEngineScript.MainWorld)
script.setInjectionPoint(QWebEngineScript.DocumentReady)
script.setRunsOnSubFrames(False)
self.profile().scripts().insert(script)
def javaScriptConsoleMessage(self, lvl, msg, line, srcID):
# not translated because console usually not visible,
# and may only accept ascii text
sys.stdout.write("JS error on line %(a)d: %(b)s" %
dict(a=line, b=msg+"\n"))
def acceptNavigationRequest(self, url, navType, isMainFrame):
if not isMainFrame:
return True
# load all other links in browser
openLink(url)
return False
def _onCmd(self, str):
self._onBridgeCmd(str)
# Main web view
##########################################################################
class AnkiWebView(QWebEngineView):
def __init__(self, parent=None):
QWebEngineView.__init__(self, parent=parent)
self.title = "default"
self._page = AnkiWebPage(self._onBridgeCmd)
self._domDone = True
self._pendingActions = []
self.setPage(self._page)
self._page.profile().setHttpCacheType(QWebEngineProfile.NoCache)
self.resetHandlers()
self.allowDrops = False
QShortcut(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(key, self,
context=Qt.WidgetWithChildrenShortcut,
activated=fn)
self.focusProxy().installEventFilter(self)
def eventFilter(self, obj, evt):
# disable pinch to zoom gesture
if isinstance(evt, QNativeGestureEvent):
return True
return False
def onEsc(self):
w = self.parent()
while w:
if isinstance(w, QDialog) or isinstance(w, QMainWindow):
from aqt import mw
# 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)
def onSelectAll(self):
self.triggerPageAction(QWebEnginePage.SelectAll)
def contextMenuEvent(self, evt):
m = QMenu(self)
a = m.addAction(_("Copy"))
a.triggered.connect(self.onCopy)
runHook("AnkiWebView.contextMenuEvent", self, m)
m.popup(QCursor.pos())
def dropEvent(self, evt):
pass
def setHtml(self, html):
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()
def zoomFactor(self):
screen = QApplication.desktop().screen()
dpi = screen.logicalDpiX()
return max(1, dpi / 96.0)
def stdHtml(self, body, css="", bodyClass="", js=["jquery.js"], head=""):
if isWin:
buttonspec = "button { font-size: 12px; font-family:'Segoe UI'; }"
fontspec = 'font-size:12px;font-family:"Segoe UI";'
elif isMac:
family="Helvetica"
fontspec = 'font-size:15px;font-family:"%s";'% \
family
buttonspec = """
button { font-size: 13px; -webkit-appearance: none; background: #fff; border: 1px solid #ccc;
border-radius:5px; font-family: Helvetica }"""
else:
buttonspec = ""
family = self.font().family()
fontspec = 'font-size:14px;font-family:%s;'%\
family
jstxt = "\n".join([self.bundledScript("webview.js")]+
[self.bundledScript(fname) for fname in js])
html="""
<!doctype html>
<html><head><title>%s</title><style>
body { zoom: %f; %s }
%s
%s</style>
%s
%s
</head>
<body class="%s">%s</body></html>""" % (
self.title,
self.zoomFactor(),
fontspec,
buttonspec,
css, jstxt,
head, bodyClass, body)
#print(html)
self.setHtml(html)
def webBundlePath(self, path):
from aqt import mw
return "http://localhost:%d/_anki/%s" % (mw.mediaServer.getPort(), path)
def bundledScript(self, fname):
return '<script src="%s"></script>' % self.webBundlePath(fname)
def bundledCSS(self, fname):
return '<link rel="stylesheet" type="text/css" href="%s">' % self.webBundlePath(fname)
def eval(self, js):
self.evalWithCallback(js, None)
def evalWithCallback(self, js, cb):
self._queueAction("eval", js, cb)
def _evalWithCallback(self, js, cb):
if cb:
self.page().runJavaScript(js, cb)
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))
def _openLinksExternally(self, url):
openLink(url)
def _onBridgeCmd(self, cmd):
# ignore webchannel messages that arrive after underlying webview
# deleted
if sip.isdeleted(self):
return
if cmd == "domDone":
self._domDone = True
self._maybeRunActions()
else:
self.onBridgeCmd(cmd)
def defaultOnBridgeCmd(self, cmd):
print("unhandled bridge cmd:", cmd)
def resetHandlers(self):
self.onBridgeCmd = self.defaultOnBridgeCmd
def adjustHeightToFit(self):
self.evalWithCallback("$(document.body).height()", self._onHeight)
def _onHeight(self, qvar):
height = int(qvar*self.zoomFactor())
self.setFixedHeight(height)