Anki/qt/aqt/utils.py
Abdo f94d05bcbe
Switch to Ruff (#4119)
* Add check:ruff build action

* Add fix:ruff action

* Add Ruff config

Mostly generated by Cursor

* Handle rest of lints

* Fix formatting

* Replace black and isort with ruff-format

* Run ruff-format

* Fix lint errors

* Remove pylint disables

* Remove .pylintrc

* Update docs

* Fix check:format not just checking

* Fix isort rule being ignored

* Sort imports

* Ensure ./ninja format also handles import sorting

* Remove unused isort cfg

* Enable unsafe fixes in fix:ruff, and enable unused var warning

* Re-run on config change; enable unnecessary ARG ignores

* Use all pycodestyle errors, and add some more commented-out ones

Latter logged on https://github.com/ankitects/anki/issues/4135
2025-06-29 14:38:35 +07:00

1386 lines
39 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 enum
import inspect
import os
import re
import shutil
import subprocess
import sys
from collections.abc import Callable, Sequence
from functools import partial, wraps
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, Union
from send2trash import send2trash
import aqt
from anki._legacy import DeprecatedNamesMixinForModule
from anki.collection import Collection, HelpPage
from anki.lang import TR, tr_legacyglobal # noqa: F401
from anki.utils import (
call,
invalid_filename,
is_mac,
is_win,
no_bundled_libs,
version_with_build,
)
from aqt.qt import *
from aqt.qt import (
PYQT_VERSION_STR,
QT_VERSION_STR, # noqa: F401
QAction,
QApplication,
QCheckBox,
QColor,
QComboBox,
QDesktopServices,
QDialog,
QDialogButtonBox,
QEvent,
QFileDialog,
QFrame,
QHeaderView,
QIcon,
QKeySequence,
QLabel,
QLineEdit,
QListWidget,
QMainWindow,
QMenu,
QMessageBox,
QMouseEvent,
QNativeGestureEvent,
QOffscreenSurface,
QOpenGLContext,
QPalette,
QPixmap,
QPlainTextEdit,
QPoint,
QPushButton,
QShortcut,
QSize,
QSplitter,
QStandardPaths,
Qt,
QTextBrowser,
QTextOption,
QTimer,
QUrl,
QVBoxLayout,
QWheelEvent,
QWidget,
pyqtSlot,
qconnect,
qtmajor,
qtminor,
qVersion,
traceback,
)
from aqt.theme import theme_manager
if TYPE_CHECKING:
TextFormat = Literal["plain", "rich", "markdown"]
def aqt_data_path() -> Path:
import _aqt.colors
data_folder = Path(inspect.getfile(_aqt.colors)).with_name("data")
if data_folder.exists():
return data_folder.absolute()
else:
# should only happen when running unit tests
print("warning, data folder not found")
return Path(".")
def aqt_data_folder() -> str:
return str(aqt_data_path())
# shortcut to access Fluent translations; set as
tr = tr_legacyglobal
HelpPageArgument = Union["HelpPage.V", str]
def openHelp(section: HelpPageArgument) -> None:
assert tr.backend is not None
backend = tr.backend()
assert backend is not None
if isinstance(section, str):
link = backend.help_page_link(page=HelpPage.INDEX) + section
else:
link = backend.help_page_link(page=section)
openLink(link)
def openLink(link: str | QUrl) -> None:
tooltip(tr.qt_misc_loading(), period=1000)
with no_bundled_libs():
QDesktopServices.openUrl(QUrl(link))
class MessageBox(QMessageBox):
def __init__(
self,
text: str,
callback: Callable[[int], None] | None = None,
parent: QWidget | None = None,
icon: QMessageBox.Icon = QMessageBox.Icon.NoIcon,
help: HelpPageArgument | None = None,
title: str = "Anki",
buttons: (
Sequence[
str | QMessageBox.StandardButton | tuple[str, QMessageBox.ButtonRole]
]
| None
) = None,
default_button: int = 0,
textFormat: Qt.TextFormat = Qt.TextFormat.PlainText,
modality: Qt.WindowModality = Qt.WindowModality.WindowModal,
) -> None:
parent = parent or aqt.mw.app.activeWindow() or aqt.mw
super().__init__(parent)
self.setText(text)
self.setWindowTitle(title)
self.setWindowModality(modality)
self.setIcon(icon)
if icon == QMessageBox.Icon.Question and theme_manager.night_mode:
img = self.iconPixmap().toImage()
img.invertPixels()
self.setIconPixmap(QPixmap(img))
self.setTextFormat(textFormat)
if buttons is None:
buttons = [QMessageBox.StandardButton.Ok]
for i, button in enumerate(buttons):
if isinstance(button, str):
b = self.addButton(button, QMessageBox.ButtonRole.ActionRole)
elif isinstance(button, QMessageBox.StandardButton):
b = self.addButton(button)
# a translator has complained the default Qt translation is inappropriate, so we override it
if button == QMessageBox.StandardButton.Discard:
assert b is not None
b.setText(tr.actions_discard())
elif isinstance(button, tuple):
b = self.addButton(button[0], button[1])
else:
continue
if callback is not None:
assert b is not None
qconnect(b.clicked, partial(callback, i))
if i == default_button:
self.setDefaultButton(b)
if help is not None:
b = self.addButton(QMessageBox.StandardButton.Help)
assert b is not None
qconnect(b.clicked, lambda: openHelp(help))
self.open()
def ask_user(
text: str,
callback: Callable[[bool], None],
defaults_yes: bool = True,
**kwargs: Any,
) -> MessageBox:
"Shows a yes/no question, passes the answer to the callback function as a bool."
return MessageBox(
text,
callback=lambda response: callback(not response),
icon=QMessageBox.Icon.Question,
buttons=[QMessageBox.StandardButton.Yes, QMessageBox.StandardButton.No],
default_button=not defaults_yes,
**kwargs,
)
def ask_user_dialog(
text: str,
callback: Callable[[int], None],
buttons: (
Sequence[str | QMessageBox.StandardButton | tuple[str, QMessageBox.ButtonRole]]
| None
) = None,
default_button: int = 1,
parent: QWidget | None = None,
title: str = "Anki",
**kwargs: Any,
) -> MessageBox:
"Shows a question to the user, passes the index of the button clicked to the callback."
if buttons is None:
buttons = [QMessageBox.StandardButton.Yes, QMessageBox.StandardButton.No]
return MessageBox(
text,
callback=callback,
icon=QMessageBox.Icon.Question,
buttons=buttons,
default_button=default_button,
parent=parent,
title=title,
**kwargs,
)
def show_info(text: str, callback: Callable | None = None, **kwargs: Any) -> MessageBox:
"Show a small info window with an OK button."
if "icon" not in kwargs:
kwargs["icon"] = QMessageBox.Icon.Information
return MessageBox(
text,
callback=(lambda _: callback()) if callback is not None else None,
**kwargs,
)
def show_warning(
text: str, callback: Callable | None = None, **kwargs: Any
) -> MessageBox:
"Show a small warning window with an OK button."
return show_info(text, icon=QMessageBox.Icon.Warning, callback=callback, **kwargs)
def show_critical(
text: str, callback: Callable | None = None, **kwargs: Any
) -> MessageBox:
"Show a small critical error window with an OK button."
return show_info(text, icon=QMessageBox.Icon.Critical, callback=callback, **kwargs)
def showWarning(
text: str,
parent: QWidget | None = None,
help: HelpPageArgument | None = None,
title: str = "Anki",
textFormat: TextFormat | None = None,
) -> int:
"Show a small warning with an OK button."
return showInfo(text, parent, help, "warning", title=title, textFormat=textFormat)
def showCritical(
text: str,
parent: QDialog | None = None,
help: str = "",
title: str = "Anki",
textFormat: TextFormat | None = None,
) -> int:
"Show a small critical error with an OK button."
return showInfo(text, parent, help, "critical", title=title, textFormat=textFormat)
def showInfo(
text: str,
parent: QWidget | None = None,
help: HelpPageArgument | None = None,
type: str = "info",
title: str = "Anki",
textFormat: TextFormat | None = None,
customBtns: list[QMessageBox.StandardButton] | None = None,
) -> int:
"Show a small info window with an OK button."
parent_widget: QWidget
if parent is None:
parent_widget = aqt.mw.app.activeWindow() or aqt.mw
else:
parent_widget = parent
if type == "warning":
icon = QMessageBox.Icon.Warning
elif type == "critical":
icon = QMessageBox.Icon.Critical
else:
icon = QMessageBox.Icon.Information
mb = QMessageBox(parent_widget)
if textFormat == "plain":
mb.setTextFormat(Qt.TextFormat.PlainText)
elif textFormat == "rich":
mb.setTextFormat(Qt.TextFormat.RichText)
elif textFormat == "markdown":
mb.setTextFormat(Qt.TextFormat.MarkdownText)
elif textFormat is not None:
raise Exception("unexpected textFormat type")
mb.setText(text)
mb.setIcon(icon)
mb.setWindowTitle(title)
if customBtns:
default = None
for btn in customBtns:
b = mb.addButton(btn)
if not default:
default = b
mb.setDefaultButton(default)
else:
b = mb.addButton(QMessageBox.StandardButton.Ok)
assert b is not None
b.setDefault(True)
if help is not None:
b = mb.addButton(QMessageBox.StandardButton.Help)
assert b is not None
qconnect(b.clicked, lambda: openHelp(help))
b.setAutoDefault(False)
return mb.exec()
def showText(
txt: str,
parent: QWidget | None = None,
type: str = "text",
run: bool = True,
geomKey: str | None = None,
minWidth: int = 500,
minHeight: int = 400,
title: str = "Anki",
copyBtn: bool = False,
plain_text_edit: bool = False,
) -> tuple[QDialog, QDialogButtonBox] | None:
if not parent:
parent = aqt.mw.app.activeWindow() or aqt.mw
diag = QDialog(parent)
diag.setWindowTitle(title)
disable_help_button(diag)
layout = QVBoxLayout(diag)
diag.setLayout(layout)
text: QPlainTextEdit | QTextBrowser
if plain_text_edit:
# used by the importer
text = QPlainTextEdit()
text.setReadOnly(True)
text.setWordWrapMode(QTextOption.WrapMode.NoWrap)
text.setPlainText(txt)
else:
text = QTextBrowser()
text.setOpenExternalLinks(True)
if type == "text":
text.setPlainText(txt)
else:
text.setHtml(txt)
layout.addWidget(text)
box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
layout.addWidget(box)
if copyBtn:
def onCopy() -> None:
clipboard = QApplication.clipboard()
assert clipboard is not None
clipboard.setText(text.toPlainText())
btn = QPushButton(tr.qt_misc_copy_to_clipboard())
qconnect(btn.clicked, onCopy)
box.addButton(btn, QDialogButtonBox.ButtonRole.ActionRole)
def onReject() -> None:
if geomKey:
saveGeom(diag, geomKey)
QDialog.reject(diag)
qconnect(box.rejected, onReject)
def onFinish() -> None:
if geomKey:
saveGeom(diag, geomKey)
qconnect(box.accepted, onFinish)
diag.setMinimumHeight(minHeight)
diag.setMinimumWidth(minWidth)
if geomKey:
restoreGeom(diag, geomKey)
if run:
diag.exec()
return None
else:
return diag, box
def askUser(
text: str,
parent: QWidget | None = None,
help: HelpPageArgument | None = None,
defaultno: bool = False,
msgfunc: Callable | None = None,
title: str = "Anki",
) -> bool:
"Show a yes/no question. Return true if yes."
if not parent:
parent = aqt.mw.app.activeWindow()
if not msgfunc:
msgfunc = QMessageBox.question
sb = QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
if help:
sb |= QMessageBox.StandardButton.Help
while 1:
if defaultno:
default = QMessageBox.StandardButton.No
else:
default = QMessageBox.StandardButton.Yes
r = msgfunc(parent, title, text, sb, default)
if r == QMessageBox.StandardButton.Help:
assert help is not None
openHelp(help)
else:
break
return r == QMessageBox.StandardButton.Yes
class ButtonedDialog(QMessageBox):
def __init__(
self,
text: str,
buttons: list[str],
parent: QWidget | None = None,
help: HelpPageArgument | None = None,
title: str = "Anki",
):
QMessageBox.__init__(self, parent)
self._buttons: list[QPushButton | None] = []
self.setWindowTitle(title)
self.help = help
self.setIcon(QMessageBox.Icon.Warning)
self.setText(text)
for b in buttons:
self._buttons.append(self.addButton(b, QMessageBox.ButtonRole.AcceptRole))
if help:
self.addButton(tr.actions_help(), QMessageBox.ButtonRole.HelpRole)
buttons.append(tr.actions_help())
def run(self) -> str:
self.exec()
clicked_button = self.clickedButton()
assert clicked_button is not None
txt = clicked_button.text()
if txt == "Help":
# FIXME stop dialog closing?
assert self.help is not None
openHelp(self.help)
# work around KDE 'helpfully' adding accelerators to button text of Qt apps
return txt.replace("&", "")
def setDefault(self, idx: int) -> None:
self.setDefaultButton(self._buttons[idx])
def askUserDialog(
text: str,
buttons: list[str],
parent: QWidget | None = None,
help: HelpPageArgument | None = None,
title: str = "Anki",
) -> ButtonedDialog:
if not parent:
parent = aqt.mw
diag = ButtonedDialog(text, buttons, parent, help, title=title)
return diag
class GetTextDialog(QDialog):
def __init__(
self,
parent: QWidget | None,
question: str,
help: HelpPageArgument | None = None,
edit: QLineEdit | None = None,
default: str = "",
title: str = "Anki",
minWidth: int = 400,
) -> None:
QDialog.__init__(self, parent)
self.setWindowTitle(title)
disable_help_button(self)
self.question = question
self.help = help
self.qlabel = QLabel(question)
self.setMinimumWidth(minWidth)
v = QVBoxLayout()
v.addWidget(self.qlabel)
if not edit:
edit = QLineEdit()
self.l = edit
if default:
self.l.setText(default)
self.l.selectAll()
v.addWidget(self.l)
buts = (
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
if help:
buts |= QDialogButtonBox.StandardButton.Help
b = QDialogButtonBox(buts) # type: ignore
v.addWidget(b)
self.setLayout(v)
ok_button = b.button(QDialogButtonBox.StandardButton.Ok)
assert ok_button is not None
qconnect(ok_button.clicked, self.accept)
cancel_button = b.button(QDialogButtonBox.StandardButton.Cancel)
assert cancel_button is not None
qconnect(cancel_button.clicked, self.reject)
if help:
help_button = b.button(QDialogButtonBox.StandardButton.Help)
assert help_button is not None
qconnect(help_button.clicked, self.helpRequested)
self.l.setFocus()
def accept(self) -> None:
return QDialog.accept(self)
def reject(self) -> None:
return QDialog.reject(self)
def helpRequested(self) -> None:
if self.help is not None:
openHelp(self.help)
def getText(
prompt: str,
parent: QWidget | None = None,
help: HelpPageArgument | None = None,
edit: QLineEdit | None = None,
default: str = "",
title: str = "Anki",
geomKey: str | None = None,
**kwargs: Any,
) -> tuple[str, int]:
"Returns (string, succeeded)."
if not parent:
parent = aqt.mw.app.activeWindow() or aqt.mw
d = GetTextDialog(
parent, prompt, help=help, edit=edit, default=default, title=title, **kwargs
)
add_close_shortcut(d)
d.setWindowModality(Qt.WindowModality.WindowModal)
if geomKey:
restoreGeom(d, geomKey)
ret = d.exec()
if geomKey and ret:
saveGeom(d, geomKey)
return (str(d.l.text()), ret)
def getOnlyText(*args: Any, **kwargs: Any) -> str:
(s, r) = getText(*args, **kwargs)
if r:
return s
else:
return ""
# fixme: these utilities could be combined into a single base class
# unused by Anki, but used by add-ons
def chooseList(
prompt: str, choices: list[str], startrow: int = 0, parent: Any | None = None
) -> int:
if not parent:
parent = aqt.mw.app.activeWindow()
d = QDialog(parent)
disable_help_button(d)
d.setWindowModality(Qt.WindowModality.WindowModal)
l = QVBoxLayout()
d.setLayout(l)
t = QLabel(prompt)
l.addWidget(t)
c = QListWidget()
c.addItems(choices)
c.setCurrentRow(startrow)
l.addWidget(c)
bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
qconnect(bb.accepted, d.accept)
l.addWidget(bb)
d.exec()
return c.currentRow()
def getTag(
parent: QWidget, deck: Collection, question: str, **kwargs: Any
) -> tuple[str, int]:
from aqt.tagedit import TagEdit
te = TagEdit(parent)
te.setCol(deck)
ret = getText(question, parent, edit=te, geomKey="getTag", **kwargs)
te.hideCompleter()
return ret
def disable_help_button(widget: QWidget) -> None:
"Disable the help button in the window titlebar."
widget.setWindowFlags(
widget.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint
)
def setWindowIcon(widget: QWidget) -> None:
icon = QIcon()
icon.addPixmap(QPixmap("icons:anki.png"), QIcon.Mode.Normal, QIcon.State.Off)
widget.setWindowIcon(icon)
# File handling
######################################################################
def getFile(
parent: QWidget,
title: str,
# single file returned unless multi=True
cb: Callable[[str | Sequence[str]], None] | None,
filter: str = "*",
dir: str | None = None,
key: str | None = None,
multi: bool = False, # controls whether a single or multiple files is returned
) -> Sequence[str] | str | None:
"Ask the user for a file."
if dir and key:
raise Exception("expected dir or key")
if not dir:
assert aqt.mw.pm.profile is not None
dirkey = f"{key}Directory"
dir = aqt.mw.pm.profile.get(dirkey, "")
else:
dirkey = None
d = QFileDialog(parent)
mode = (
QFileDialog.FileMode.ExistingFiles
if multi
else QFileDialog.FileMode.ExistingFile
)
d.setFileMode(mode)
assert dir is not None
if os.path.exists(dir):
d.setDirectory(dir)
d.setWindowTitle(title)
d.setNameFilter(filter)
ret = []
def accept() -> None:
files = list(d.selectedFiles())
if dirkey:
assert aqt.mw.pm.profile is not None
dir = os.path.dirname(files[0])
aqt.mw.pm.profile[dirkey] = dir
result = files if multi else files[0]
if cb:
cb(result)
ret.append(result)
qconnect(d.accepted, accept)
if key:
restoreState(d, key)
d.exec()
if key:
saveState(d, key)
return ret[0] if ret else None
def running_in_sandbox():
"""Check whether running in Flatpak or Snap. When in such a sandbox, Qt
will not report the true location of user-chosen files, but instead a
temporary location from which the sandboxing software will copy the file to
the user-chosen destination. Thus file renames are impossible and caching
the reported file location is unhelpful."""
in_flatpak = (
QStandardPaths.locate(
QStandardPaths.StandardLocation.RuntimeLocation,
"flatpak-info",
)
!= ""
)
in_snap = bool(os.environ.get("SNAP"))
return in_flatpak or in_snap
def getSaveFile(
parent: QDialog,
title: str,
dir_description: str,
key: str,
ext: str,
fname: str = "",
) -> str | None:
"""Ask the user for a file to save. Use DIR_DESCRIPTION as config
variable. The file dialog will default to open with FNAME."""
assert aqt.mw.pm.profile is not None
config_key = f"{dir_description}Directory"
defaultPath = QStandardPaths.writableLocation(
QStandardPaths.StandardLocation.DocumentsLocation
)
base = aqt.mw.pm.profile.get(config_key, defaultPath)
path = os.path.join(base, fname)
file = QFileDialog.getSaveFileName(
parent,
title,
path,
f"{key} (*{ext})",
options=QFileDialog.Option.DontConfirmOverwrite,
)[0]
if file and not running_in_sandbox():
# add extension
if not file.lower().endswith(ext):
file += ext
# save new default
dir = os.path.dirname(file)
aqt.mw.pm.profile[config_key] = dir
# check if it exists
if os.path.exists(file) and not askUser(
tr.qt_misc_this_file_exists_are_you_sure(), parent
):
return None
return file
class _QtStateKeyKind(enum.Enum):
HEADER = enum.auto()
SPLITTER = enum.auto()
STATE = enum.auto()
GEOMETRY = enum.auto()
def _qt_state_key(kind: _QtStateKeyKind, key: str) -> str:
"""Construct a key used to save/restore geometry, state, etc.
Adds Qt version number to key so that different data is saved per Qt version,
preventing crashes and bugs when restoring data saved with a different Qt version.
"""
qt_suffix = f"{qtmajor}.{qtminor}" if qtmajor > 5 else ""
return f"{key}{kind.name.capitalize()}{qt_suffix}"
def saveGeom(widget: QWidget, key: str) -> None:
# restoring a fullscreen window breaks the tab functionality of 5.15
if not widget.isFullScreen() or qtmajor == 6:
assert aqt.mw.pm.profile is not None
key = _qt_state_key(_QtStateKeyKind.GEOMETRY, key)
aqt.mw.pm.profile[key] = widget.saveGeometry()
def restoreGeom(
widget: QWidget,
key: str,
adjustSize: bool = False,
default_size: tuple[int, int] | None = None,
) -> None:
assert aqt.mw.pm.profile is not None
key = _qt_state_key(_QtStateKeyKind.GEOMETRY, key)
if existing_geom := aqt.mw.pm.profile.get(key):
widget.restoreGeometry(existing_geom)
ensureWidgetInScreenBoundaries(widget)
elif adjustSize:
widget.adjustSize()
elif default_size:
widget.resize(*default_size)
def ensureWidgetInScreenBoundaries(widget: QWidget) -> None:
window = widget.window()
assert window is not None
handle = window.windowHandle()
if not handle:
# window has not yet been shown, retry later
aqt.mw.progress.timer(
50, lambda: ensureWidgetInScreenBoundaries(widget), False, parent=widget
)
return
# ensure widget is smaller than screen bounds
screen = handle.screen()
assert screen is not None
geom = screen.availableGeometry()
wsize = widget.size()
cappedWidth = min(geom.width(), wsize.width())
cappedHeight = min(geom.height(), wsize.height())
if cappedWidth > wsize.width() or cappedHeight > wsize.height():
widget.resize(QSize(cappedWidth, cappedHeight))
# ensure widget is inside top left
wpos = widget.pos()
x = max(geom.x(), wpos.x())
y = max(geom.y(), wpos.y())
# and bottom right
x = min(x, geom.width() + geom.x() - cappedWidth)
y = min(y, geom.height() + geom.y() - cappedHeight)
if x != wpos.x() or y != wpos.y():
widget.move(x, y)
def saveState(widget: QFileDialog | QMainWindow, key: str) -> None:
assert aqt.mw.pm.profile is not None
key = _qt_state_key(_QtStateKeyKind.STATE, key)
aqt.mw.pm.profile[key] = widget.saveState()
def restoreState(widget: QFileDialog | QMainWindow, key: str) -> None:
assert aqt.mw.pm.profile is not None
key = _qt_state_key(_QtStateKeyKind.STATE, key)
if data := aqt.mw.pm.profile.get(key):
widget.restoreState(data)
def saveSplitter(widget: QSplitter, key: str) -> None:
assert aqt.mw.pm.profile is not None
key = _qt_state_key(_QtStateKeyKind.SPLITTER, key)
aqt.mw.pm.profile[key] = widget.saveState()
def restoreSplitter(widget: QSplitter, key: str) -> None:
assert aqt.mw.pm.profile is not None
key = _qt_state_key(_QtStateKeyKind.SPLITTER, key)
if data := aqt.mw.pm.profile.get(key):
widget.restoreState(data)
def saveHeader(widget: QHeaderView, key: str) -> None:
assert aqt.mw.pm.profile is not None
key = _qt_state_key(_QtStateKeyKind.HEADER, key)
aqt.mw.pm.profile[key] = widget.saveState()
def restoreHeader(widget: QHeaderView, key: str) -> None:
assert aqt.mw.pm.profile is not None
key = _qt_state_key(_QtStateKeyKind.HEADER, key)
if state := aqt.mw.pm.profile.get(key):
widget.restoreState(state)
def save_is_checked(widget: QCheckBox, key: str) -> None:
assert aqt.mw.pm.profile is not None
key += "IsChecked"
aqt.mw.pm.profile[key] = widget.isChecked()
def restore_is_checked(widget: QCheckBox, key: str) -> None:
assert aqt.mw.pm.profile is not None
key += "IsChecked"
if aqt.mw.pm.profile.get(key) is not None:
widget.setChecked(aqt.mw.pm.profile[key])
def save_combo_index_for_session(widget: QComboBox, key: str) -> None:
textKey = f"{key}ComboActiveText"
indexKey = f"{key}ComboActiveIndex"
aqt.mw.pm.session[textKey] = widget.currentText()
aqt.mw.pm.session[indexKey] = widget.currentIndex()
def restore_combo_index_for_session(
widget: QComboBox, history: list[str], key: str
) -> None:
textKey = f"{key}ComboActiveText"
indexKey = f"{key}ComboActiveIndex"
text = aqt.mw.pm.session.get(textKey)
index = aqt.mw.pm.session.get(indexKey)
if text is not None and index is not None:
if index < len(history) and history[index] == text:
widget.setCurrentIndex(index)
def save_combo_history(comboBox: QComboBox, history: list[str], name: str) -> str:
assert aqt.mw.pm.profile is not None
name += "BoxHistory"
line_edit = comboBox.lineEdit()
assert line_edit is not None
text_input = line_edit.text()
if text_input in history:
history.remove(text_input)
history.insert(0, text_input)
history = history[:50]
comboBox.clear()
comboBox.addItems(history)
aqt.mw.pm.session[name] = text_input
aqt.mw.pm.profile[name] = history
return text_input
def restore_combo_history(comboBox: QComboBox, name: str) -> list[str]:
assert aqt.mw.pm.profile is not None
name += "BoxHistory"
history = aqt.mw.pm.profile.get(name, [])
comboBox.addItems([""] + history)
if history:
session_input = aqt.mw.pm.session.get(name)
if session_input and session_input == history[0]:
line_edit = comboBox.lineEdit()
assert line_edit is not None
line_edit.setText(session_input)
line_edit.selectAll()
return history
def mungeQA(col: Collection, txt: str) -> str:
print("mungeQA() deprecated; use mw.prepare_card_text_for_display()")
txt = col.media.escape_media_filenames(txt)
return txt
def openFolder(path: str) -> None:
if is_win:
subprocess.run(["explorer", f"file://{path}"], check=False)
else:
with no_bundled_libs():
QDesktopServices.openUrl(QUrl.fromLocalFile(path))
def show_in_folder(path: str) -> None:
if is_win:
_show_in_folder_win32(path)
elif is_mac:
script = f"""
tell application "Finder"
activate
select POSIX file "{path}"
end tell
"""
call(osascript_to_args(script))
else:
# For linux, there are multiple file managers. Let's test if one of the
# most common file managers is found and use it in case it is installed.
# If none of this list are installed, use a fallback. The fallback
# might open the image in a web browser, image viewer or others,
# depending on the users defaults.
file_managers = [
"nautilus", # GNOME
"dolphin", # KDE
"pcmanfm", # LXDE
"thunar", # XFCE
"nemo", # Cinnamon
"caja", # MATE
]
available_file_manager = None
# Test if a file manager is installed and use it, fallback otherwise
for file_manager in file_managers:
if shutil.which(file_manager):
available_file_manager = file_manager
break
if available_file_manager:
subprocess.run([available_file_manager, path], check=False)
else:
# Just open the file in any other platform
with no_bundled_libs():
QDesktopServices.openUrl(QUrl.fromLocalFile(path))
def _show_in_folder_win32(path: str) -> None:
import win32con
import win32gui
from aqt import mw
def focus_explorer():
hwnd = win32gui.FindWindow("CabinetWClass", None)
if hwnd:
win32gui.ShowWindow(hwnd, win32con.SW_RESTORE)
win32gui.SetForegroundWindow(hwnd)
subprocess.run(["explorer", "/select,", path], check=False)
mw.progress.single_shot(500, focus_explorer)
def osascript_to_args(script: str):
args = [
item
for line in script.splitlines()
for item in ("-e", line.strip())
if line.strip()
]
return ["osascript"] + args
def shortcut(key: str) -> str:
if is_mac:
return re.sub("(?i)ctrl", "Command", key)
return key
def maybeHideClose(bbox: QDialogButtonBox) -> None:
if is_mac:
b = bbox.button(QDialogButtonBox.StandardButton.Close)
if b:
bbox.removeButton(b)
def add_close_shortcut(widg: QWidget) -> None:
if not is_mac:
return
shortcut = QShortcut(QKeySequence("Ctrl+W"), widg)
qconnect(shortcut.activated, widg.close)
def downArrow() -> str:
if is_win:
return ""
# windows 10 is lacking the smaller arrow on English installs
return ""
def current_window() -> QWidget | None:
if widget := QApplication.focusWidget():
return widget.window()
else:
return None
def send_to_trash(path: Path) -> None:
"Place file/folder in recycling bin, or delete permanently on failure."
if not path.exists():
return
try:
send2trash(path)
except Exception as exc:
# Linux users may not have a trash folder set up
print("trash failure:", path, exc)
if path.is_dir():
shutil.rmtree(path)
else:
path.unlink()
# Tooltips
######################################################################
_tooltipTimer: QTimer | None = None
_tooltipLabel: QLabel | None = None
def tooltip(
msg: str,
period: int = 3000,
parent: QWidget | None = None,
x_offset: int = 0,
y_offset: int = 100,
) -> None:
global _tooltipTimer, _tooltipLabel
class CustomLabel(QLabel):
silentlyClose = True
def mousePressEvent(self, evt: QMouseEvent | None) -> None:
assert evt is not None
evt.accept()
self.hide()
closeTooltip()
aw = parent or aqt.mw.app.activeWindow() or aqt.mw
lab = CustomLabel(
f"""<table cellpadding=10>
<tr>
<td>{msg}</td>
</tr>
</table>""",
aw,
)
lab.setFrameStyle(QFrame.Shape.Panel)
lab.setLineWidth(2)
lab.setWindowFlags(Qt.WindowType.ToolTip)
if not theme_manager.night_mode:
p = QPalette()
p.setColor(QPalette.ColorRole.Window, QColor("#feffc4"))
p.setColor(QPalette.ColorRole.WindowText, QColor("#000000"))
lab.setPalette(p)
lab.move(aw.mapToGlobal(QPoint(0 + x_offset, aw.height() - y_offset)))
lab.show()
_tooltipTimer = aqt.mw.progress.timer(
period, closeTooltip, False, requiresCollection=False, parent=aw
)
_tooltipLabel = lab
def closeTooltip() -> None:
global _tooltipLabel, _tooltipTimer
if _tooltipLabel:
try:
_tooltipLabel.deleteLater()
except RuntimeError:
# already deleted as parent window closed
pass
_tooltipLabel = None
if _tooltipTimer:
try:
_tooltipTimer.deleteLater()
except RuntimeError:
pass
_tooltipTimer = None
# true if invalid; print warning
def checkInvalidFilename(str: str, dirsep: bool = True) -> bool:
bad = invalid_filename(str, dirsep)
if bad:
showWarning(tr.qt_misc_the_following_character_can_not_be(val=bad))
return True
return False
# Menus
######################################################################
# This code will be removed in the future, please don't rely on it.
MenuListChild = Union["SubMenu", QAction, "MenuItem", "MenuList"]
class MenuList:
def __init__(self) -> None:
traceback.print_stack(file=sys.stdout)
print(
"MenuList will be removed; please copy it into your add-on's code if you need it."
)
self.children: list[MenuListChild | None] = []
def addItem(self, title: str, func: Callable) -> MenuItem:
item = MenuItem(title, func)
self.children.append(item)
return item
def addSeparator(self) -> None:
self.children.append(None)
def addMenu(self, title: str) -> SubMenu:
submenu = SubMenu(title)
self.children.append(submenu)
return submenu
def addChild(self, child: SubMenu | QAction | MenuList) -> None:
self.children.append(child)
def renderTo(self, qmenu: QMenu) -> None:
for child in self.children:
if child is None:
qmenu.addSeparator()
elif isinstance(child, QAction):
qmenu.addAction(child)
else:
child.renderTo(qmenu)
def popupOver(self, widget: QPushButton) -> None:
qmenu = QMenu()
self.renderTo(qmenu)
qmenu.exec(widget.mapToGlobal(QPoint(0, 0)))
class SubMenu(MenuList):
def __init__(self, title: str) -> None:
super().__init__()
self.title = title
def renderTo(self, menu: QMenu) -> None:
submenu = menu.addMenu(self.title)
assert submenu is not None
super().renderTo(submenu)
class MenuItem:
def __init__(self, title: str, func: Callable) -> None:
self.title = title
self.func = func
def renderTo(self, qmenu: QMenu) -> None:
a = qmenu.addAction(self.title)
assert a is not None
qconnect(a.triggered, self.func)
def qtMenuShortcutWorkaround(qmenu: QMenu) -> None:
for act in qmenu.actions():
act.setShortcutVisibleInContextMenu(True)
######################################################################
def disallow_full_screen() -> bool:
"""Test for OpenGl on Windows, which is known to cause issues with full screen mode."""
from aqt import mw
from aqt.profiles import VideoDriver
return is_win and (
mw.pm.video_driver() == VideoDriver.OpenGL
and not os.environ.get("ANKI_SOFTWAREOPENGL")
)
def add_ellipsis_to_action_label(*actions: QAction | QPushButton) -> None:
"""Pass actions to add '...' to their labels, indicating that more input is
required before they can be performed.
This approach is used so that the same fluent translations can be used on
mobile, where the '...' convention does not exist.
"""
for action in actions:
action.setText(tr.actions_with_ellipsis(action=action.text()))
def supportText() -> str:
import platform
from aqt import mw
platname = platform.platform()
return """\
Anki {} {}
Python {} Qt {} PyQt {}
Platform: {}
""".format(
version_with_build(),
"(ao)" if mw.addonManager.dirty else "",
platform.python_version(),
qVersion(),
PYQT_VERSION_STR,
platname,
)
######################################################################
# adapted from version detection in qutebrowser
def opengl_vendor() -> str | None:
if qtmajor != 5:
return "unknown"
old_context = QOpenGLContext.currentContext()
old_surface = None if old_context is None else old_context.surface()
surface = QOffscreenSurface()
surface.create()
ctx = QOpenGLContext()
ok = ctx.create()
if not ok:
return None
ok = ctx.makeCurrent(surface)
if not ok:
return None
try:
if ctx.isOpenGLES():
# Can't use versionFunctions there
return None
vp = QOpenGLVersionProfile() # type: ignore
vp.setVersion(2, 0)
try:
vf = ctx.versionFunctions(vp) # type: ignore
except ImportError:
return None
if vf is None:
return None
return vf.glGetString(vf.GL_VENDOR)
finally:
ctx.doneCurrent()
if old_context and old_surface:
old_context.makeCurrent(old_surface)
def gfxDriverIsBroken() -> bool:
driver = opengl_vendor()
return driver == "nouveau"
######################################################################
def startup_info() -> Any:
"Use subprocess.Popen(startupinfo=...) to avoid opening a console window."
if sys.platform != "win32":
return None
si = subprocess.STARTUPINFO() # pytype: disable=module-attr
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW # pytype: disable=module-attr
return si
def ensure_editor_saved(func: Callable) -> Callable:
"""Ensure the current editor's note is saved before running the wrapped function.
Must be used on functions that may be invoked from a shortcut key while the
editor has focus. For functions that can't be activated while the editor has
focus, you don't need this.
Will look for the editor as self.editor.
"""
@wraps(func)
def decorated(self: Any, *args: Any, **kwargs: Any) -> None:
self.editor.call_after_note_saved(lambda: func(self, *args, **kwargs))
return decorated
def skip_if_selection_is_empty(func: Callable) -> Callable:
"""Make the wrapped method a no-op and show a hint if the table selection is empty."""
@wraps(func)
def decorated(self: Any, *args: Any, **kwargs: Any) -> None:
if self.table.len_selection() > 0:
func(self, *args, **kwargs)
else:
tooltip(tr.browsing_no_selection())
return decorated
def no_arg_trigger(func: Callable) -> Callable:
"""Tells Qt this function takes no args.
This ensures PyQt doesn't attempt to pass a `toggled` arg
into functions connected to a `triggered` signal.
"""
return pyqtSlot()(func) # type: ignore
def is_gesture_or_zoom_event(evt: QEvent) -> bool:
"""If the event is a gesture and/or will trigger zoom.
Includes zoom by pinching, and Ctrl-scrolling on Win and Linux.
"""
return isinstance(evt, QNativeGestureEvent) or (
isinstance(evt, QWheelEvent)
and not is_mac
and KeyboardModifiersPressed().control
)
class KeyboardModifiersPressed:
"Util for type-safe checks of currently-pressed modifier keys."
def __init__(self) -> None:
from aqt import mw
self._modifiers = mw.app.keyboardModifiers()
@property
def shift(self) -> bool:
return bool(self._modifiers & Qt.KeyboardModifier.ShiftModifier)
@property
def control(self) -> bool:
return bool(self._modifiers & Qt.KeyboardModifier.ControlModifier)
@property
def alt(self) -> bool:
return bool(self._modifiers & Qt.KeyboardModifier.AltModifier)
@property
def meta(self) -> bool:
return bool(self._modifiers & Qt.KeyboardModifier.MetaModifier)
# add-ons attempting to import isMac from this module :-(
_deprecated_names = DeprecatedNamesMixinForModule(globals())
if not TYPE_CHECKING:
def __getattr__(name: str) -> Any:
return _deprecated_names.__getattr__(name)