~foosoft/anki-connect

056e722187551ba91fc7e06b654e75284004ecca — oakkitten 2 years ago 901d92b
Edit dialog: fix editor buttons on Anki 2.1.50

Also add a few tests for the buttons to make sure
that they get actually added and enabled/disabled.
2 files changed, 154 insertions(+), 33 deletions(-)

M plugin/edit.py
M tests/test_edit.py
M plugin/edit.py => plugin/edit.py +73 -31
@@ 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, "&lt;", "Previous", "Alt+Left")
        add("next", self.show_next, "&gt;", "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)))

        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);
            }});
        disable_previous = not(history.has_note_to_left_of(self.note))
        disable_next = not(history.has_note_to_right_of(self.note))

        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)};
        """)

    ##########################################################################

M tests/test_edit.py => tests/test_edit.py +81 -2
@@ 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):

Do not follow this link