Anki/qt/aqt/editor.py
Damien Elmes 04996c77f3
Migrate build system to uv (#4074)
* Migrate build system to uv

Closes #3787, and is a step towards #3081 and #4022

This change breaks our PyOxidizer bundling process. While we probably
could update it to work with the new venvs & lockfile, my intention
is to use this as a base to try out a uv-based packager/installer.

Some notes about the changes:

- Use uv for python download + venv installation
- Drop python/requirements* in favour of pyproject files / uv.lock
- Bumped to latest Python 3.9 version. The move to 3.13 should be
a fairly trivial change when we're ready.
- Dropped the old write_wheel.py in favour of uv/hatchling. This has
the unfortunate side-effect of dropping leading zeros in our wheels,
which we could try hack around in the future.
- Switch to Qt 6.7 for the dev repo, as it's the first PyQt version
with a Linux/ARM WebEngine wheel.
- Unified our macOS deployment target with minimum required for ARM.
- Dropped unused fluent python files
- Dropped unused python license generation
- Dropped helpers to run under Qt 5, as our wheels were already
requiring Qt 6 to install.

* Build action to create universal uv binary

* Drop some PyOxidizer-related files

* Use Windows ARM64 cargo/node binaries during build

We can't provide ARM64 wheels to users yet due to #4079, but we can
at least speed up the build.

The rustls -> native-tls change on Windows is because ring requires
clang to compile for ARM64, and I figured it's best to keep our Windows
deps consistent. We already built the wheels with native-tls.

* Make libankihelper a universal library

We were shipping a single arch library in a purelib, leading to
breakages when running on a different platform.

* Use Python wheel for mpv/lame on Windows/Mac

This is convenient, but suboptimal on a Mac at the moment. The first
run of mpv will take a number of seconds for security checks to run,
and our mpv code ends up timing out, repeating the process each time.
Our installer stub will need to invoke mpv once first to get it validated.

We could address this by distributing the audio with the installer/stub,
or perhaps by putting the binaries in a .pkg file that's notarized+stapled
and then included in the wheel.

* Add some helper scripts to build a fully-locked wheel

* Initial macOS launcher prototype

* Add a hidden env var to preload our libs and audio helpers on macOS

* qt/bundle -> qt/launcher

- remove more of the old bundling code
- handle app icon

* Fat binary, notarization & dmg

* Publish wheels on testpypi for testing

* Use our Python pin for the launcher too

* Python cleanups

* Extend launcher to other platforms + more

- Switch to Qt 6.8 for repo default, as 6.7 depends on an older
libwebp/tiff which is unavailable on newer installs
- Drop tools/mac-x86, as we no longer need to test against Qt 5
- Add flags to cross compile wheels on Mac and Linux
- Bump glibc target to 2_36, building on Debian Stable
- Increase mpv timeout on macOS to allow for initial gatekeeper checks
- Ship both arm64 and amd64 uv on Linux, with a bash stub to pick
the appropriate arch.

* Fix pylint on Linux

* Fix failure to run from /usr/local/bin

* Remove remaining pyoxidizer refs, and clean up duplicate release folder

* Rust dep updates

- Rust 1.87 for now (1.88 due out in around a week)
- Nom looks involved, so I left it for now
- prost-reflect depends on a new prost version that got yanked

* Python 3.13 + dep updates

Updated protoc binaries + add helper in order to try fix build breakage.
Ended up being due to an AI-generated update to pip-system-certs that
was not reviewed carefully enough:
https://gitlab.com/alelec/pip-system-certs/-/issues/36

The updated mypy/black needed some tweaks to our files.

* Windows compilation fixes

* Automatically run Anki after installing on Windows

* Touch pyproject.toml upon install, so we check for updates

* Update Python deps

- urllib3 for CVE
- pip-system-certs got fixed
- markdown/pytest also updated
2025-06-19 14:03:16 +07:00

653 lines
20 KiB
Python

# 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 base64
import functools
import json
import mimetypes
import os
from collections.abc import Callable
from enum import Enum
from random import randrange
from typing import Any
from anki._legacy import deprecated
from anki.cards import Card
from anki.hooks import runFilter
from anki.models import NotetypeDict, StockNotetype
from anki.notes import Note, NoteId
from anki.utils import is_win
from aqt import AnkiQt, gui_hooks
from aqt.operations.notetype import update_notetype_legacy
from aqt.qt import *
from aqt.sound import av_player
from aqt.utils import shortcut, showWarning
from aqt.webview import AnkiWebView, AnkiWebViewKind
pics = ("jpg", "jpeg", "png", "gif", "svg", "webp", "ico", "avif")
audio = (
"3gp",
"aac",
"avi",
"flac",
"flv",
"m4a",
"mkv",
"mov",
"mp3",
"mp4",
"mpeg",
"mpg",
"oga",
"ogg",
"ogv",
"ogx",
"opus",
"spx",
"swf",
"wav",
"webm",
)
class EditorMode(Enum):
ADD_CARDS = 0
EDIT_CURRENT = 1
BROWSER = 2
class EditorState(Enum):
"""
Current input state of the editing UI.
"""
INITIAL = -1
FIELDS = 0
IO_PICKER = 1
IO_MASKS = 2
IO_FIELDS = 3
def on_editor_ready(func: Callable) -> Callable:
@functools.wraps(func)
def decorated(self: Editor, *args: Any, **kwargs: Any) -> None:
if self._ready:
func(self, *args, **kwargs)
else:
self._ready_callbacks.append(lambda: func(self, *args, **kwargs))
return decorated
class Editor:
"""The screen that embeds an editing widget should listen for changes via
the `operation_did_execute` hook, and call set_note() when the editor needs
redrawing.
The editor will cause that hook to be fired when it saves changes. To avoid
an unwanted refresh, the parent widget should check if handler
corresponds to this editor instance, and ignore the change if it does.
"""
def __init__(
self,
mw: AnkiQt,
widget: QWidget,
parentWindow: QWidget,
addMode: bool | None = None,
*,
editor_mode: EditorMode = EditorMode.EDIT_CURRENT,
) -> None:
self.mw = mw
self.widget = widget
self.parentWindow = parentWindow
self.nid: NoteId | None = None
# legacy argument provided?
if addMode is not None:
editor_mode = EditorMode.ADD_CARDS if addMode else EditorMode.EDIT_CURRENT
self.addMode = editor_mode is EditorMode.ADD_CARDS
self.editorMode = editor_mode
self.currentField: int | None = None
# Similar to currentField, but not set to None on a blur. May be
# outside the bounds of the current notetype.
self.last_field_index: int | None = None
# used when creating a copy of an existing note
self.orig_note_id: NoteId | None = None
# current card, for card layout
self.card: Card | None = None
self.state: EditorState = EditorState.INITIAL
# used for the io mask editor's context menu
self.last_io_image_path: str | None = None
self._ready = False
self._ready_callbacks: list[Callable[[], None]] = []
self._init_links()
self.setupOuter()
self.add_webview()
self.setupWeb()
self.setupShortcuts()
# gui_hooks.editor_did_init(self)
# Initial setup
############################################################
def setupOuter(self) -> None:
l = QVBoxLayout()
l.setContentsMargins(0, 0, 0, 0)
l.setSpacing(0)
self.widget.setLayout(l)
self.outerLayout = l
def add_webview(self) -> None:
self.web = EditorWebView(self.widget, self)
self.web.set_bridge_command(self.onBridgeCmd, self)
self.outerLayout.addWidget(self.web, 1)
def setupWeb(self) -> None:
editor_key = self.mw.pm.editor_key(self.editorMode)
self.web.load_sveltekit_page(f"editor/?mode={editor_key}")
self.web.allow_drops = True
def _set_ready(self) -> None:
lefttopbtns: list[str] = []
gui_hooks.editor_did_init_left_buttons(lefttopbtns, self)
lefttopbtns_defs = [
f"uiPromise.then((noteEditor) => noteEditor.toolbar.notetypeButtons.appendButton({{ component: editorToolbar.Raw, props: {{ html: {json.dumps(button)} }} }}, -1));"
for button in lefttopbtns
]
lefttopbtns_js = "\n".join(lefttopbtns_defs)
righttopbtns: list[str] = []
gui_hooks.editor_did_init_buttons(righttopbtns, self)
# legacy filter
righttopbtns = runFilter("setupEditorButtons", righttopbtns, self)
righttopbtns_defs = ", ".join([json.dumps(button) for button in righttopbtns])
righttopbtns_js = (
f"""
require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].toolbar.toolbar.append({{
component: editorToolbar.AddonButtons,
id: "addons",
props: {{ buttons: [ {righttopbtns_defs} ] }},
}}));
"""
if len(righttopbtns) > 0
else ""
)
self.web.eval(f"{lefttopbtns_js} {righttopbtns_js}")
gui_hooks.editor_did_init(self)
self._ready = True
for cb in self._ready_callbacks:
cb()
# Top buttons
######################################################################
def resourceToData(self, path: str) -> str:
"""Convert a file (specified by a path) into a data URI."""
if not os.path.exists(path):
raise FileNotFoundError
mime, _ = mimetypes.guess_type(path)
with open(path, "rb") as fp:
data = fp.read()
data64 = b"".join(base64.encodebytes(data).splitlines())
return f"data:{mime};base64,{data64.decode('ascii')}"
def addButton(
self,
icon: str | None,
cmd: str,
func: Callable[[Editor], None],
tip: str = "",
label: str = "",
id: str | None = None,
toggleable: bool = False,
keys: str | None = None,
disables: bool = True,
rightside: bool = True,
) -> str:
"""Assign func to bridge cmd, register shortcut, return button"""
def wrapped_func(editor: Editor) -> None:
self.call_after_note_saved(functools.partial(func, editor), keepFocus=True)
self._links[cmd] = wrapped_func
if keys:
def on_activated() -> None:
wrapped_func(self)
if toggleable:
# generate a random id for triggering toggle
id = id or str(randrange(1_000_000))
def on_hotkey() -> None:
on_activated()
self.web.eval(
f'toggleEditorButton(document.getElementById("{id}"));'
)
else:
on_hotkey = on_activated
QShortcut( # type: ignore
QKeySequence(keys),
self.widget,
activated=on_hotkey,
)
btn = self._addButton(
icon,
cmd,
tip=tip,
label=label,
id=id,
toggleable=toggleable,
disables=disables,
rightside=rightside,
)
return btn
def _addButton(
self,
icon: str | None,
cmd: str,
tip: str = "",
label: str = "",
id: str | None = None,
toggleable: bool = False,
disables: bool = True,
rightside: bool = True,
) -> str:
title_attribute = tip
if icon:
if icon.startswith("qrc:/"):
iconstr = icon
elif os.path.isabs(icon):
iconstr = self.resourceToData(icon)
else:
iconstr = f"/_anki/imgs/{icon}.png"
image_element = f'<img class="topbut" src="{iconstr}">'
else:
image_element = ""
if not label and icon:
label_element = ""
elif label:
label_element = label
else:
label_element = cmd
title_attribute = shortcut(title_attribute)
id_attribute_assignment = f"id={id}" if id else ""
class_attribute = "linkb" if rightside else "rounded"
if not disables:
class_attribute += " perm"
return f"""<button tabindex=-1
{id_attribute_assignment}
class="anki-addon-button {class_attribute}"
type="button"
title="{title_attribute}"
data-cantoggle="{int(toggleable)}"
data-command="{cmd}"
>
{image_element}
{label_element}
</button>"""
def setupShortcuts(self) -> None:
# if a third element is provided, enable shortcut even when no field selected
cuts: list[tuple] = []
gui_hooks.editor_did_init_shortcuts(cuts, self)
for row in cuts:
if len(row) == 2:
keys, fn = row # pylint: disable=unbalanced-tuple-unpacking
fn = self._addFocusCheck(fn)
else:
keys, fn, _ = row
QShortcut(QKeySequence(keys), self.widget, activated=fn) # type: ignore
def _addFocusCheck(self, fn: Callable) -> Callable:
def checkFocus() -> None:
if self.currentField is None:
return
fn()
return checkFocus
def onFields(self) -> None:
self.call_after_note_saved(self._onFields)
def _onFields(self) -> None:
from aqt.fields import FieldDialog
FieldDialog(self.mw, self.note_type(), parent=self.parentWindow)
def onCardLayout(self) -> None:
self.call_after_note_saved(self._onCardLayout)
def _onCardLayout(self) -> None:
from aqt.clayout import CardLayout
if self.card:
ord = self.card.ord
else:
ord = 0
assert self.note is not None
CardLayout(
self.mw,
self.note,
ord=ord,
parent=self.parentWindow,
fill_empty=False,
)
if is_win:
self.parentWindow.activateWindow()
# JS->Python bridge
######################################################################
def onBridgeCmd(self, cmd: str) -> Any:
# focus lost or key/button pressed?
if cmd.startswith("blur") or cmd.startswith("key"):
(type, ord_str) = cmd.split(":", 1)
ord = int(ord_str)
if type == "blur":
self.currentField = None
# run any filters
if self.note and gui_hooks.editor_did_unfocus_field(
False, self.note, ord
):
# something updated the note; update it after a subsequent focus
# event has had time to fire
self.mw.progress.timer(
100, self.loadNoteKeepingFocus, False, parent=self.widget
)
else:
if self.note:
gui_hooks.editor_did_fire_typing_timer(self.note)
# focused into field?
elif cmd.startswith("focus"):
(type, num) = cmd.split(":", 1)
self.last_field_index = self.currentField = int(num)
if self.note:
gui_hooks.editor_did_focus_field(self.note, self.currentField)
elif cmd.startswith("toggleStickyAll"):
model = self.note_type()
flds = model["flds"]
any_sticky = any([fld["sticky"] for fld in flds])
result = []
for fld in flds:
if not any_sticky or fld["sticky"]:
fld["sticky"] = not fld["sticky"]
result.append(fld["sticky"])
update_notetype_legacy(parent=self.mw, notetype=model).run_in_background(
initiator=self
)
return result
elif cmd.startswith("toggleSticky"):
(type, num) = cmd.split(":", 1)
ord = int(num)
model = self.note_type()
fld = model["flds"][ord]
new_state = not fld["sticky"]
fld["sticky"] = new_state
update_notetype_legacy(parent=self.mw, notetype=model).run_in_background(
initiator=self
)
return new_state
elif cmd.startswith("saveTags"):
if self.note:
gui_hooks.editor_did_update_tags(self.note)
elif cmd.startswith("editorState"):
(_, new_state_id, old_state_id) = cmd.split(":", 2)
self.signal_state_change(
EditorState(int(new_state_id)), EditorState(int(old_state_id))
)
elif cmd.startswith("ioImageLoaded"):
(_, path_or_nid_data) = cmd.split(":", 1)
path_or_nid = json.loads(path_or_nid_data)
if self.addMode:
gui_hooks.editor_mask_editor_did_load_image(self, path_or_nid)
else:
gui_hooks.editor_mask_editor_did_load_image(
self, NoteId(int(path_or_nid))
)
elif cmd == "editorReady":
self._set_ready()
elif cmd in self._links:
return self._links[cmd](self)
else:
print("uncaught cmd", cmd)
def signal_state_change(
self, new_state: EditorState, old_state: EditorState
) -> None:
self.state = new_state
gui_hooks.editor_state_did_change(self, new_state, old_state)
# Setting/unsetting the current note
######################################################################
def set_nid(
self,
nid: NoteId | None,
mid: int,
focus_to: int | None = None,
) -> None:
"Make note with ID `nid` the current note."
self.nid = nid
self.currentField = None
self.load_note(mid, focus_to=focus_to)
@deprecated(replaced_by=set_nid)
def set_note(
self,
note: Note | None,
hide: bool = True,
focusTo: int | None = None,
) -> None:
"Make NOTE the current note."
self.currentField = None
if note:
self.nid = note.id
self.load_note(mid=note.mid, focus_to=focusTo)
elif hide:
self.widget.hide()
def loadNoteKeepingFocus(self) -> None:
self.loadNote(self.currentField)
@on_editor_ready
def load_note(self, mid: int, focus_to: int | None = None) -> None:
self.widget.show()
def oncallback(arg: Any) -> None:
if not self.nid:
return
# we currently do this synchronously to ensure we load before the
# sidebar on browser startup
if focus_to is not None:
self.web.setFocus()
gui_hooks.editor_did_load_note(self)
assert self.mw.pm.profile is not None
js = f"loadNote({json.dumps(self.nid)}, {mid}, {json.dumps(focus_to)}, {json.dumps(self.orig_note_id)});"
if self.note:
js = gui_hooks.editor_will_load_note(js, self.note, self)
self.web.evalWithCallback(
f'require("anki/ui").loaded.then(() => {{ {js} }})', oncallback
)
@deprecated(replaced_by=load_note)
def loadNote(self, focusTo: int | None = None) -> None:
assert self.note is not None
self.load_note(self.note.mid, focus_to=focusTo)
def call_after_note_saved(
self, callback: Callable, keepFocus: bool = False
) -> None:
"Save unsaved edits then call callback()."
if not self.nid:
# calling code may not expect the callback to fire immediately
self.mw.progress.single_shot(10, callback)
return
self.web.evalWithCallback("saveNow(%d)" % keepFocus, lambda res: callback())
saveNow = call_after_note_saved
def fieldsAreBlank(self, previousNote: Note | None = None) -> bool:
if not self.note:
return True
m = self.note_type()
for c, f in enumerate(self.note.fields):
f = f.replace("<br>", "").strip()
notChangedvalues = {"", "<br>"}
if previousNote and m["flds"][c]["sticky"]:
notChangedvalues.add(previousNote.fields[c].replace("<br>", "").strip())
if f not in notChangedvalues:
return False
return True
def cleanup(self) -> None:
av_player.stop_and_clear_queue_if_caller(self.editorMode)
self.set_note(None)
# prevent any remaining evalWithCallback() events from firing after C++ object deleted
if self.web:
self.web.cleanup()
self.web = None # type: ignore
setNote = set_note
# Paste/drag&drop
######################################################################
def onPaste(self) -> None:
self.web.onPaste()
def onCut(self) -> None:
self.web.onCut()
def onCopy(self) -> None:
self.web.onCopy()
# Image occlusion
######################################################################
def current_notetype_is_image_occlusion(self) -> bool:
if not self.note:
return False
return (
self.note_type().get("originalStockKind", None)
== StockNotetype.OriginalStockKind.ORIGINAL_STOCK_KIND_IMAGE_OCCLUSION
)
def setup_mask_editor(self, image_path: str) -> None:
try:
if self.editorMode == EditorMode.ADD_CARDS:
self.setup_mask_editor_for_new_note(image_path=image_path)
else:
assert self.note is not None
self.setup_mask_editor_for_existing_note(image_path=image_path)
except Exception as e:
showWarning(str(e))
def setup_mask_editor_for_new_note(self, image_path: str):
"""Set-up IO mask editor for adding new notes
Presupposes that active editor notetype is an image occlusion notetype
Args:
image_path: Absolute path to image.
"""
self.web.eval(
'require("anki/ui").loaded.then(() =>'
f"setupMaskEditorForNewNote({json.dumps(image_path)})"
"); "
)
def setup_mask_editor_for_existing_note(self, image_path: str | None = None):
"""Set-up IO mask editor for editing existing notes
Presupposes that active editor notetype is an image occlusion notetype
Args:
image_path: (Optional) Absolute path to image that should replace current
image
"""
self.web.eval(
'require("anki/ui").loaded.then(() =>'
f"setupMaskEditorForExistingNote({json.dumps(image_path)})"
"); "
)
# Links from HTML
######################################################################
def _init_links(self) -> None:
self._links: dict[str, Callable] = dict(
fields=Editor.onFields,
cards=Editor.onCardLayout,
paste=Editor.onPaste,
cut=Editor.onCut,
copy=Editor.onCopy,
)
@property
def note(self) -> Note | None:
if self.nid is None:
return None
return self.mw.col.get_note(self.nid)
def note_type(self) -> NotetypeDict:
assert self.note is not None
note_type = self.note.note_type()
assert note_type is not None
return note_type
# Pasting, drag & drop, and keyboard layouts
######################################################################
class EditorWebView(AnkiWebView):
def __init__(self, parent: QWidget, editor: Editor) -> None:
AnkiWebView.__init__(self, kind=AnkiWebViewKind.EDITOR)
self.editor = editor
self.setAcceptDrops(True)
self.settings().setAttribute( # type: ignore
QWebEngineSettings.WebAttribute.JavascriptCanPaste, True
)
self.settings().setAttribute( # type: ignore
QWebEngineSettings.WebAttribute.JavascriptCanAccessClipboard, True
)
gui_hooks.editor_web_view_did_init(self)
def onCut(self) -> None:
self.triggerPageAction(QWebEnginePage.WebAction.Cut)
def onCopy(self) -> None:
self.triggerPageAction(QWebEnginePage.WebAction.Copy)
def onPaste(self) -> None:
self.triggerPageAction(QWebEnginePage.WebAction.Paste)