diff --git a/plugin/edit.py b/plugin/edit.py index 066df0e..2ae9a89 100644 --- a/plugin/edit.py +++ b/plugin/edit.py @@ -1,5 +1,6 @@ import aqt import aqt.editor +import aqt.browser.previewer from aqt import gui_hooks from aqt.qt import QDialog, Qt, QKeySequence, QShortcut from aqt.utils import disable_help_button, restoreGeom, saveGeom, tooltip @@ -24,6 +25,8 @@ from anki.utils import ids2str DOMAIN_PREFIX = "foosoft.ankiconnect." +anki_version = tuple(int(segment) for segment in aqt.appVersion.split(".")) + def get_note_by_note_id(note_id): return aqt.mw.col.get_note(note_id) @@ -225,8 +228,6 @@ class Edit(aqt.editcurrent.EditCurrent): if changes.note_text and handler is not self.editor: self.reload_notes_after_user_action_elsewhere() - # adjusting buttons right after initializing doesn't have any effect; - # this seems to do the trick def editor_did_load_note(self, _editor): self.enable_disable_next_and_previous_buttons() @@ -304,29 +305,66 @@ class Edit(aqt.editcurrent.EditCurrent): gui_hooks.editor_did_init.append(self.add_preview_button) gui_hooks.editor_did_init_buttons.append(self.add_right_hand_side_buttons) - self.editor = aqt.editor.Editor(aqt.mw, self.form.fieldsArea, self) + # on Anki 2.1.50, browser mode makes the Preview button visible + extra_kwargs = {} if anki_version < (2, 1, 50) else { + "editor_mode": aqt.editor.EditorMode.BROWSER + } + + self.editor = aqt.editor.Editor(aqt.mw, self.form.fieldsArea, self, + **extra_kwargs) gui_hooks.editor_did_init_buttons.remove(self.add_right_hand_side_buttons) gui_hooks.editor_did_init.remove(self.add_preview_button) - # taken from `setupEditor` of browser.py - # PreviewButton calls pycmd `preview`, which is hardcoded. - # copying _links is needed so that opening Anki's browser does not - # screw them up as they are apparently shared between instances?! + # * on Anki < 2.1.50, make the button via js (`setupEditor` of browser.py); + # also, make a copy of _links so that opening Anki's browser does not + # screw them up as they are apparently shared between instances?! + # the last part seems to have been fixed in Anki 2.1.50 + # * on Anki 2.1.50, the button is created by setting editor mode, + # see above; so we only need to add the link. def add_preview_button(self, editor): QShortcut(QKeySequence("Ctrl+Shift+P"), self, self.show_preview) - editor._links = editor._links.copy() - editor._links["preview"] = self.show_preview - editor.web.eval(""" - $editorToolbar.then(({notetypeButtons}) => - notetypeButtons.appendButton( - {component: editorToolbar.PreviewButton, id: 'preview'} - ) - ); - """) + if anki_version < (2, 1, 50): + editor._links = editor._links.copy() + editor.web.eval(""" + $editorToolbar.then(({notetypeButtons}) => + notetypeButtons.appendButton( + {component: editorToolbar.PreviewButton, id: 'preview'} + ) + ); + """) + editor._links["preview"] = lambda _editor: self.show_preview() and None + + # * on Anki < 2.1.50, button style is okay-ish from get-go, + # except when disabled; adding class `btn` fixes that; + # * on Anki 2.1.50, buttons have weird font size and are square'; + # the style below makes them in line with left-hand side buttons def add_right_hand_side_buttons(self, buttons, editor): + if anki_version < (2, 1, 50): + extra_button_class = "btn" + else: + extra_button_class = "anki-connect-button" + editor.web.eval(""" + (function(){ + const style = document.createElement("style"); + style.innerHTML = ` + .anki-connect-button { + white-space: nowrap; + width: auto; + padding: 0 2px; + font-size: var(--base-font-size); + } + .anki-connect-button:disabled { + pointer-events: none; + opacity: .4; + } + `; + document.head.appendChild(style); + })(); + """) + def add(cmd, function, label, tip, keys): button_html = editor.addButton( icon=None, @@ -338,30 +376,34 @@ class Edit(aqt.editcurrent.EditCurrent): keys=keys, ) - # adding class `btn` properly styles buttons when disabled - button_html = button_html.replace('class="', 'class="btn ') + button_html = button_html.replace('class="', + f'class="{extra_button_class} ') buttons.append(button_html) add("browse", self.show_browser, "Browse", "Browse", "Ctrl+F") add("previous", self.show_previous, "<", "Previous", "Alt+Left") add("next", self.show_next, ">", "Next", "Alt+Right") + def run_javascript_after_toolbar_ready(self, js): + js = f"setTimeout(function() {{ {js} }}, 1)" + if anki_version < (2, 1, 50): + js = f'$editorToolbar.then(({{ toolbar }}) => {js})' + else: + js = f'require("anki/ui").loaded.then(() => {js})' + self.editor.web.eval(js) + def enable_disable_next_and_previous_buttons(self): def to_js(boolean): return "true" if boolean else "false" - disable_previous = to_js(not(history.has_note_to_left_of(self.note))) - disable_next = to_js(not(history.has_note_to_right_of(self.note))) + disable_previous = not(history.has_note_to_left_of(self.note)) + disable_next = not(history.has_note_to_right_of(self.note)) - self.editor.web.eval(f""" - $editorToolbar.then(({{ toolbar }}) => {{ - setTimeout(function() {{ - document.getElementById("{DOMAIN_PREFIX}previous") - .disabled = {disable_previous}; - document.getElementById("{DOMAIN_PREFIX}next") - .disabled = {disable_next}; - }}, 1); - }}); + self.run_javascript_after_toolbar_ready(f""" + document.getElementById("{DOMAIN_PREFIX}previous") + .disabled = {to_js(disable_previous)}; + document.getElementById("{DOMAIN_PREFIX}next") + .disabled = {to_js(disable_next)}; """) ########################################################################## diff --git a/tests/test_edit.py b/tests/test_edit.py index 6886fa8..71b1615 100644 --- a/tests/test_edit.py +++ b/tests/test_edit.py @@ -1,8 +1,63 @@ +from dataclasses import dataclass +from unittest.mock import MagicMock + import aqt.operations.note import pytest -from conftest import get_dialog_instance -from plugin.edit import Edit, DecentPreviewer, history +from conftest import get_dialog_instance, wait_until +from plugin.edit import Edit, DecentPreviewer, history, DOMAIN_PREFIX + + +NOTHING = object() + + +class Value: + def __init__(self): + self.value = NOTHING + + def set(self, value): + self.value = value + + def has_been_set(self): + return self.value is not NOTHING + + +@dataclass +class JavascriptDialogButtonManipulator: + dialog: ... + + def eval_js(self, js): + evaluation_result = Value() + self.dialog.editor.web.evalWithCallback(js, evaluation_result.set) + wait_until(evaluation_result.has_been_set) + return evaluation_result.value + + def wait_until_toolbar_buttons_are_ready(self): + ready_flag = Value() + self.dialog.editor._links["set_ready_flag"] = ready_flag.set # noqa + self.dialog.run_javascript_after_toolbar_ready("pycmd('set_ready_flag');") + wait_until(ready_flag.has_been_set) + + # preview button doesn't have an id, so find by label + def click_preview_button(self): + self.eval_js(""" + document.evaluate("//button[text()='Preview']", document) + .iterateNext() + .click() + """) + + def click_button(self, button_id): + self.eval_js(f""" + document.getElementById("{DOMAIN_PREFIX}{button_id}").click() + """) + + def is_button_disabled(self, button_id): + return self.eval_js(f""" + document.getElementById("{DOMAIN_PREFIX}{button_id}").disabled + """) + + +############################################################################## def test_edit_dialog_opens(setup): @@ -99,6 +154,30 @@ class TestPreviewDialog: assert dialog._should_enable_next() is next_enabled +class TestButtons: + @pytest.fixture + def manipulator(self, setup): + dialog = Edit.open_dialog_and_show_note_with_id(setup.note1_id) + return JavascriptDialogButtonManipulator(dialog) + + def test_preview_button_can_be_clicked(self, manipulator, monkeypatch): + monkeypatch.setattr(manipulator.dialog, "show_preview", MagicMock()) + manipulator.wait_until_toolbar_buttons_are_ready() + manipulator.click_preview_button() + wait_until(lambda: manipulator.dialog.show_preview.call_count == 1) + + def test_addon_buttons_can_be_clicked(self, manipulator): + manipulator.wait_until_toolbar_buttons_are_ready() + manipulator.click_button(button_id="browse") + wait_until(lambda: get_dialog_instance("Browser") is not None) + + def test_addon_buttons_get_disabled_enabled(self, setup, manipulator): + Edit.open_dialog_and_show_note_with_id(setup.note2_id) + manipulator.wait_until_toolbar_buttons_are_ready() + assert manipulator.is_button_disabled("previous") is False + assert manipulator.is_button_disabled("next") is True + + class TestHistory: @pytest.fixture(autouse=True) def cleanup(self):