Anki/qt/aqt/webview.py
Damien Elmes 7dcbc7efec basic night mode support
Forces the Fusion theme when running night mode, so we don't need
to work around platform themes that don't respond to the defined
palette.

Feedback/suggestions on the chosen colours welcome - _vars.scss is the
file to change if you want to experiment with adjustments.
2020-01-23 17:27:07 +10:00

455 lines
15 KiB
Python

# 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
import sys
from typing import Any, List, Optional, Tuple
from anki.lang import _
from anki.utils import isLin, isMac, isWin
from aqt import gui_hooks
from aqt.qt import *
from aqt.theme import theme_manager
from aqt.utils import openLink
# Page for debug messages
##########################################################################
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)
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 = 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");
});
"""
)
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
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
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
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)
# Main web view
##########################################################################
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
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
key, self, context=Qt.WidgetWithChildrenShortcut, activated=fn
)
QShortcut( # type: ignore
QKeySequence("ctrl+shift+v"),
self,
context=Qt.WidgetWithChildrenShortcut,
activated=self.onPaste,
)
def eventFilter(self, obj, evt):
# disable pinch to zoom gesture
if isinstance(evt, QNativeGestureEvent):
return True
elif evt.type() == QEvent.MouseButtonRelease:
if evt.button() == Qt.MidButton and isLin:
self.onMiddleClickPaste()
return True
return False
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 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()
def zoomFactor(self):
# overridden scale factor?
webscale = os.environ.get("ANKI_WEBSCALE")
if webscale:
return float(webscale)
if isMac:
return 1
screen = QApplication.desktop().screen()
dpi = screen.logicalDpiX()
factor = dpi / 96.0
if isLin:
factor = max(1, factor)
return factor
# 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
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)
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()
if isWin:
# 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
fontspec = "font-size:12px;font-family:%s;" % family
elif isMac:
family = "Helvetica"
fontspec = 'font-size:15px;font-family:"%s";' % family
widgetspec = """
button { font-size: 13px; -webkit-appearance: none; background: #fff; border: 1px solid #ccc;
border-radius:5px; font-family: Helvetica }"""
else:
family = self.font().family()
color_hl_txt = palette.color(QPalette.HighlightedText).name()
color_btn = palette.color(QPalette.Button).name()
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;
}""" % {
"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
head = mw.baseHTML() + head + csstxt + jstxt
body_class = theme_manager.body_class()
html = """
<!doctype html>
<html><head>
<title>{}</title>
<style>
body {{ zoom: {}; background: {}; {} }}
{}
</style>
{}
</head>
<body class="{}">{}</body>
</html>""".format(
self.title,
self.zoomFactor(),
self._getWindowColor().name(),
fontspec,
widgetspec,
head,
body_class,
body,
)
# print(html)
self.setHtml(html)
def webBundlePath(self, path):
from aqt import mw
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):
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:
def handler(val):
if self._shouldIgnoreWebEvent():
print("ignored late js callback", cb)
return
cb(val)
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))
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
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:
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