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