diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index da085252c..3808f137e 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -2133,7 +2133,7 @@ update cards set usn=?, mod=?, did=? where id in """ frm.fields.addItems(fields) self._dupesButton = None # links - frm.webView.onBridgeCmd = self.dupeLinkClicked + frm.webView.set_bridge_command(self.dupeLinkClicked, "find_dupes") def onFin(code): saveGeom(d, "findDupes") diff --git a/qt/aqt/clayout.py b/qt/aqt/clayout.py index e950640bf..aa4ef8253 100644 --- a/qt/aqt/clayout.py +++ b/qt/aqt/clayout.py @@ -216,6 +216,9 @@ class CardLayout(QDialog): pform.backWeb.stdHtml( self.mw.reviewer.revHtml(), css=["reviewer.css"], js=jsinc ) + # specify a context for add-ons + pform.frontWeb.set_bridge_command(lambda msg: None, "card_layout") + pform.backWeb.set_bridge_command(lambda msg: None, "card_layout") def updateMainArea(self): if self._isCloze(): diff --git a/qt/aqt/deckbrowser.py b/qt/aqt/deckbrowser.py index d648b05ca..a152d75f0 100644 --- a/qt/aqt/deckbrowser.py +++ b/qt/aqt/deckbrowser.py @@ -8,25 +8,25 @@ import aqt from anki.errors import DeckRenameError from anki.lang import _, ngettext from anki.utils import fmtTimeSpan, ids2str -from aqt import gui_hooks +from aqt import AnkiQt, gui_hooks from aqt.qt import * from aqt.sound import av_player +from aqt.toolbar import BottomBar from aqt.utils import askUser, getOnlyText, openHelp, openLink, shortcut, showWarning class DeckBrowser: _dueTree: Any - def __init__(self, mw): + def __init__(self, mw: AnkiQt) -> None: self.mw = mw self.web = mw.web - self.bottom = aqt.toolbar.BottomBar(mw, mw.bottomWeb) + self.bottom = BottomBar(mw, mw.bottomWeb) self.scrollPos = QPoint(0, 0) def show(self): av_player.stop_and_clear_queue() - self.web.resetHandlers() - self.web.onBridgeCmd = self._linkHandler + self.web.set_bridge_command(self._linkHandler, "deck_browser") self._renderPage() def refresh(self): @@ -330,7 +330,7 @@ where id > ?""", b ) self.bottom.draw(buf) - self.bottom.web.onBridgeCmd = self._linkHandler + self.bottom.web.set_bridge_command(self._linkHandler, "deck_browser_bottom_bar") def _onShared(self): openLink(aqt.appShared + "decks/") diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index 1009e2210..b7795bf7f 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -96,7 +96,7 @@ class Editor: self.web = EditorWebView(self.widget, self) self.web.title = "editor" self.web.allowDrops = True - self.web.onBridgeCmd = self.onBridgeCmd + self.web.set_bridge_command(self.onBridgeCmd, "editor") self.outerLayout.addWidget(self.web, 1) righttopbtns: List[str] = [ diff --git a/qt/aqt/gui_hooks.py b/qt/aqt/gui_hooks.py index 4f1db5852..019956c92 100644 --- a/qt/aqt/gui_hooks.py +++ b/qt/aqt/gui_hooks.py @@ -913,6 +913,50 @@ class _UndoStateDidChangeHook: undo_state_did_change = _UndoStateDidChangeHook() +class _WebviewDidReceiveJsMessageFilter: + """Used to handle pycmd() messages sent from Javascript. + + Message is the string passed to pycmd(). Context is what was + passed to set_bridge_command(), such as 'editor' or 'reviewer'. + + For messages you don't want to handle, return handled unchanged. + + If you handle a message and don't want it passed to the original + bridge command handler, return (True, None). + + If you want to pass a value to pycmd's result callback, you can + return it with (True, some_value).""" + + _hooks: List[Callable[[Tuple[bool, Any], str, str], Tuple[bool, Any]]] = [] + + def append( + self, cb: Callable[[Tuple[bool, Any], str, str], Tuple[bool, Any]] + ) -> None: + """(handled: Tuple[bool, Any], message: str, context: str)""" + self._hooks.append(cb) + + def remove( + self, cb: Callable[[Tuple[bool, Any], str, str], Tuple[bool, Any]] + ) -> None: + if cb in self._hooks: + self._hooks.remove(cb) + + def __call__( + self, handled: Tuple[bool, Any], message: str, context: str + ) -> Tuple[bool, Any]: + for filter in self._hooks: + try: + handled = filter(handled, message, context) + except: + # if the hook fails, remove it + self._hooks.remove(filter) + raise + return handled + + +webview_did_receive_js_message = _WebviewDidReceiveJsMessageFilter() + + class _WebviewWillShowContextMenuHook: _hooks: List[Callable[["aqt.webview.AnkiWebView", QMenu], None]] = [] diff --git a/qt/aqt/main.py b/qt/aqt/main.py index e9368440f..8ed44aae6 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -643,8 +643,9 @@ from the profile screen." if self.resetModal: # we don't have to change the webview, as we have a covering window return - self.web.resetHandlers() - self.web.onBridgeCmd = lambda url: self.delayedMaybeReset() + self.web.set_bridge_command( + lambda url: self.delayedMaybeReset(), "reset_required" + ) i = _("Waiting for editing to finish.") b = self.button("refresh", _("Resume Now"), id="resume") self.web.stdHtml( diff --git a/qt/aqt/overview.py b/qt/aqt/overview.py index 619b3a61c..e5a9c9891 100644 --- a/qt/aqt/overview.py +++ b/qt/aqt/overview.py @@ -2,24 +2,26 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +from __future__ import annotations + import aqt from anki.lang import _ from aqt.sound import av_player +from aqt.toolbar import BottomBar from aqt.utils import askUserDialog, openLink, shortcut, tooltip class Overview: "Deck overview." - def __init__(self, mw): + def __init__(self, mw: aqt.AnkiQt) -> None: self.mw = mw self.web = mw.web - self.bottom = aqt.toolbar.BottomBar(mw, mw.bottomWeb) + self.bottom = BottomBar(mw, mw.bottomWeb) def show(self): av_player.stop_and_clear_queue() - self.web.resetHandlers() - self.web.onBridgeCmd = self._linkHandler + self.web.set_bridge_command(self._linkHandler, "overview") self.mw.setStateShortcuts(self._shortcutKeys()) self.refresh() @@ -235,7 +237,7 @@ to their original deck.""" b ) self.bottom.draw(buf) - self.bottom.web.onBridgeCmd = self._linkHandler + self.bottom.web.set_bridge_command(self._linkHandler, "overview_bottom") # Studying more ###################################################################### diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index 3091fea4d..925c29e75 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -10,7 +10,6 @@ import re import unicodedata as ucd from typing import List, Optional -import aqt from anki import hooks from anki.cards import Card from anki.lang import _, ngettext @@ -19,6 +18,7 @@ from anki.utils import bodyClass, stripHTML from aqt import AnkiQt, gui_hooks from aqt.qt import * from aqt.sound import av_player, getAudio +from aqt.toolbar import BottomBar from aqt.utils import ( askUserDialog, downArrow, @@ -42,15 +42,14 @@ class Reviewer: self._current_side_audio: Optional[List[AVTag]] = None self.typeCorrect = None # web init happens before this is set self.state: Optional[str] = None - self.bottom = aqt.toolbar.BottomBar(mw, mw.bottomWeb) + self.bottom = BottomBar(mw, mw.bottomWeb) hooks.card_did_leech.append(self.onLeech) def show(self): self.mw.col.reset() - self.web.resetHandlers() self.mw.setStateShortcuts(self._shortcutKeys()) - self.web.onBridgeCmd = self._linkHandler - self.bottom.web.onBridgeCmd = self._linkHandler + self.web.set_bridge_command(self._linkHandler, "reviewer") + self.bottom.web.set_bridge_command(self._linkHandler, "reviewer_bottom") self._reps = None self.nextCard() diff --git a/qt/aqt/toolbar.py b/qt/aqt/toolbar.py index 369d3cfe5..6d35b6172 100644 --- a/qt/aqt/toolbar.py +++ b/qt/aqt/toolbar.py @@ -2,12 +2,16 @@ # -*- coding: utf-8 -*- # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +from __future__ import annotations + +import aqt from anki.lang import _ from aqt.qt import * +from aqt.webview import AnkiWebView class Toolbar: - def __init__(self, mw, web): + def __init__(self, mw: aqt.AnkiQt, web: AnkiWebView) -> None: self.mw = mw self.web = web self.link_handlers = { @@ -22,7 +26,7 @@ class Toolbar: self.web.requiresCol = False def draw(self): - self.web.onBridgeCmd = self._linkHandler + self.web.set_bridge_command(self._linkHandler, "top_toolbar") self.web.stdHtml(self._body % self._centerLinks(), css=["toolbar.css"]) self.web.adjustHeightToFit() @@ -107,7 +111,8 @@ class BottomBar(Toolbar): """ def draw(self, buf): - self.web.onBridgeCmd = self._linkHandler + # note: some screens may override this + self.web.set_bridge_command(self._linkHandler, "bottom_toolbar") self.web.stdHtml( self._centerBody % buf, css=["toolbar.css", "toolbar-bottom.css"] ) diff --git a/qt/aqt/webview.py b/qt/aqt/webview.py index 86ce429ca..233f86b90 100644 --- a/qt/aqt/webview.py +++ b/qt/aqt/webview.py @@ -4,6 +4,7 @@ import json import math import sys +from typing import Any, List, Optional, Tuple from anki.lang import _ from anki.utils import isLin, isMac, isWin @@ -99,18 +100,21 @@ class AnkiWebPage(QWebEnginePage): # type: ignore class AnkiWebView(QWebEngineView): # type: ignore - def __init__(self, parent=None): - QWebEngineView.__init__(self, parent=parent) + 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 = [] + self._pendingActions: List[Tuple[str, List[Any]]] = [] self.requiresCol = True self.setPage(self._page) - self._page.profile().setHttpCacheType(QWebEngineProfile.NoCache) + self._page.profile().setHttpCacheType(QWebEngineProfile.NoCache) # type: ignore self.resetHandlers() self.allowDrops = False self._filterSet = False @@ -119,7 +123,7 @@ class AnkiWebView(QWebEngineView): # type: ignore self, context=Qt.WidgetWithChildrenShortcut, activated=self.onEsc, - ) + ) # type: ignore if isMac: for key, fn in [ (QKeySequence.Copy, self.onCopy), @@ -129,13 +133,13 @@ class AnkiWebView(QWebEngineView): # type: ignore ]: QShortcut( key, self, context=Qt.WidgetWithChildrenShortcut, activated=fn - ) + ) # type: ignore QShortcut( QKeySequence("ctrl+shift+v"), self, context=Qt.WidgetWithChildrenShortcut, activated=self.onPaste, - ) + ) # type: ignore def eventFilter(self, obj, evt): # disable pinch to zoom gesture @@ -391,7 +395,7 @@ body {{ zoom: {}; background: {}; {} }} return True return False - def _onBridgeCmd(self, cmd): + def _onBridgeCmd(self, cmd: str) -> Any: if self._shouldIgnoreWebEvent(): print("ignored late bridge cmd", cmd) return @@ -404,13 +408,21 @@ body {{ zoom: {}; background: {}; {} }} self._domDone = True self._maybeRunActions() else: - return self.onBridgeCmd(cmd) + 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): + 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) @@ -429,3 +441,11 @@ body {{ zoom: {}; background: {}; {} }} 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 diff --git a/qt/tools/genhooks_gui.py b/qt/tools/genhooks_gui.py index a2a8196f3..d1b73c499 100644 --- a/qt/tools/genhooks_gui.py +++ b/qt/tools/genhooks_gui.py @@ -65,11 +65,6 @@ hooks = [ args=["browser: aqt.browser.Browser"], legacy_hook="browser.rowChanged", ), - Hook( - name="webview_will_show_context_menu", - args=["webview: aqt.webview.AnkiWebView", "menu: QMenu"], - legacy_hook="AnkiWebView.contextMenuEvent", - ), # States ################### Hook( @@ -98,6 +93,30 @@ hooks = [ legacy_hook="reset", doc="Called when the interface needs to be redisplayed after non-trivial changes have been made.", ), + # Webview + ################### + Hook( + name="webview_did_receive_js_message", + args=["handled: Tuple[bool, Any]", "message: str", "context: str"], + return_type="Tuple[bool, Any]", + doc="""Used to handle pycmd() messages sent from Javascript. + + Message is the string passed to pycmd(). Context is what was + passed to set_bridge_command(), such as 'editor' or 'reviewer'. + + For messages you don't want to handle, return handled unchanged. + + If you handle a message and don't want it passed to the original + bridge command handler, return (True, None). + + If you want to pass a value to pycmd's result callback, you can + return it with (True, some_value).""", + ), + Hook( + name="webview_will_show_context_menu", + args=["webview: aqt.webview.AnkiWebView", "menu: QMenu"], + legacy_hook="AnkiWebView.contextMenuEvent", + ), # Main ################### Hook(name="profile_did_open", legacy_hook="profileLoaded"),