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.
This commit is contained in:
oakkitten 2022-04-12 23:22:51 +01:00
parent 901d92b067
commit 056e722187
2 changed files with 152 additions and 31 deletions

View File

@ -1,5 +1,6 @@
import aqt import aqt
import aqt.editor import aqt.editor
import aqt.browser.previewer
from aqt import gui_hooks from aqt import gui_hooks
from aqt.qt import QDialog, Qt, QKeySequence, QShortcut from aqt.qt import QDialog, Qt, QKeySequence, QShortcut
from aqt.utils import disable_help_button, restoreGeom, saveGeom, tooltip from aqt.utils import disable_help_button, restoreGeom, saveGeom, tooltip
@ -24,6 +25,8 @@ from anki.utils import ids2str
DOMAIN_PREFIX = "foosoft.ankiconnect." DOMAIN_PREFIX = "foosoft.ankiconnect."
anki_version = tuple(int(segment) for segment in aqt.appVersion.split("."))
def get_note_by_note_id(note_id): def get_note_by_note_id(note_id):
return aqt.mw.col.get_note(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: if changes.note_text and handler is not self.editor:
self.reload_notes_after_user_action_elsewhere() 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): def editor_did_load_note(self, _editor):
self.enable_disable_next_and_previous_buttons() self.enable_disable_next_and_previous_buttons()
@ -304,20 +305,28 @@ class Edit(aqt.editcurrent.EditCurrent):
gui_hooks.editor_did_init.append(self.add_preview_button) gui_hooks.editor_did_init.append(self.add_preview_button)
gui_hooks.editor_did_init_buttons.append(self.add_right_hand_side_buttons) 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_buttons.remove(self.add_right_hand_side_buttons)
gui_hooks.editor_did_init.remove(self.add_preview_button) gui_hooks.editor_did_init.remove(self.add_preview_button)
# taken from `setupEditor` of browser.py # * on Anki < 2.1.50, make the button via js (`setupEditor` of browser.py);
# PreviewButton calls pycmd `preview`, which is hardcoded. # also, make a copy of _links so that opening Anki's browser does not
# copying _links is needed so that opening Anki's browser does not
# screw them up as they are apparently shared between instances?! # 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): def add_preview_button(self, editor):
QShortcut(QKeySequence("Ctrl+Shift+P"), self, self.show_preview) QShortcut(QKeySequence("Ctrl+Shift+P"), self, self.show_preview)
if anki_version < (2, 1, 50):
editor._links = editor._links.copy() editor._links = editor._links.copy()
editor._links["preview"] = self.show_preview
editor.web.eval(""" editor.web.eval("""
$editorToolbar.then(({notetypeButtons}) => $editorToolbar.then(({notetypeButtons}) =>
notetypeButtons.appendButton( notetypeButtons.appendButton(
@ -326,7 +335,36 @@ class Edit(aqt.editcurrent.EditCurrent):
); );
""") """)
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): 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): def add(cmd, function, label, tip, keys):
button_html = editor.addButton( button_html = editor.addButton(
icon=None, icon=None,
@ -338,30 +376,34 @@ class Edit(aqt.editcurrent.EditCurrent):
keys=keys, keys=keys,
) )
# adding class `btn` properly styles buttons when disabled button_html = button_html.replace('class="',
button_html = button_html.replace('class="', 'class="btn ') f'class="{extra_button_class} ')
buttons.append(button_html) buttons.append(button_html)
add("browse", self.show_browser, "Browse", "Browse", "Ctrl+F") add("browse", self.show_browser, "Browse", "Browse", "Ctrl+F")
add("previous", self.show_previous, "&lt;", "Previous", "Alt+Left") add("previous", self.show_previous, "&lt;", "Previous", "Alt+Left")
add("next", self.show_next, "&gt;", "Next", "Alt+Right") 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 enable_disable_next_and_previous_buttons(self):
def to_js(boolean): def to_js(boolean):
return "true" if boolean else "false" return "true" if boolean else "false"
disable_previous = to_js(not(history.has_note_to_left_of(self.note))) disable_previous = not(history.has_note_to_left_of(self.note))
disable_next = to_js(not(history.has_note_to_right_of(self.note))) disable_next = not(history.has_note_to_right_of(self.note))
self.editor.web.eval(f""" self.run_javascript_after_toolbar_ready(f"""
$editorToolbar.then(({{ toolbar }}) => {{
setTimeout(function() {{
document.getElementById("{DOMAIN_PREFIX}previous") document.getElementById("{DOMAIN_PREFIX}previous")
.disabled = {disable_previous}; .disabled = {to_js(disable_previous)};
document.getElementById("{DOMAIN_PREFIX}next") document.getElementById("{DOMAIN_PREFIX}next")
.disabled = {disable_next}; .disabled = {to_js(disable_next)};
}}, 1);
}});
""") """)
########################################################################## ##########################################################################

View File

@ -1,8 +1,63 @@
from dataclasses import dataclass
from unittest.mock import MagicMock
import aqt.operations.note import aqt.operations.note
import pytest import pytest
from conftest import get_dialog_instance from conftest import get_dialog_instance, wait_until
from plugin.edit import Edit, DecentPreviewer, history 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): def test_edit_dialog_opens(setup):
@ -99,6 +154,30 @@ class TestPreviewDialog:
assert dialog._should_enable_next() is next_enabled 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: class TestHistory:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def cleanup(self): def cleanup(self):