anki/qt/aqt/utils.py

1072 lines
30 KiB
Python
Raw Normal View History

2019-02-05 04:59:03 +01:00
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
2019-12-20 10:19:03 +01:00
import os
import re
import subprocess
import sys
from functools import wraps
from typing import TYPE_CHECKING, Any, Literal, Sequence, no_type_check
import aqt
from anki._legacy import DeprecatedNamesMixinForModule
2021-07-22 10:07:13 +02:00
from anki.collection import Collection, HelpPage
2021-03-26 04:48:26 +01:00
from anki.lang import TR, tr_legacyglobal # pylint: disable=unused-import
PEP8 for rest of pylib (#1451) * PEP8 dbproxy.py * PEP8 errors.py * PEP8 httpclient.py * PEP8 lang.py * PEP8 latex.py * Add decorator to deprectate key words * Make replacement for deprecated attribute optional * Use new helper `_print_replacement_warning()` * PEP8 media.py * PEP8 rsbackend.py * PEP8 sound.py * PEP8 stdmodels.py * PEP8 storage.py * PEP8 sync.py * PEP8 tags.py * PEP8 template.py * PEP8 types.py * Fix DeprecatedNamesMixinForModule The class methods need to be overridden with instance methods, so every module has its own dicts. * Use `# pylint: disable=invalid-name` instead of id * PEP8 utils.py * Only decorate `__getattr__` with `@no_type_check` * Fix mypy issue with snakecase Importing it from `anki._vendor` raises attribute errors. * Format * Remove inheritance of DeprecatedNamesMixin There's almost no shared code now and overriding classmethods with instance methods raises mypy issues. * Fix traceback frames of deprecation warnings * remove fn/TimedLog (dae) Neither Anki nor add-ons appear to have been using it * fix some issues with stringcase use (dae) - the wheel was depending on the PyPI version instead of our vendored version - _vendor:stringcase should not have been listed in the anki py_library. We already include the sources in py_srcs, and need to refer to them directly. By listing _vendor:stringcase as well, we were making a top-level stringcase library available, which would have only worked for distributing because the wheel definition was also incorrect. - mypy errors are what caused me to mistakenly add the above - they were because the type: ignore at the top of stringcase.py was causing mypy to completely ignore the file, so it was not aware of any attributes it contained.
2021-10-25 06:50:13 +02:00
from anki.utils import (
invalid_filename,
is_mac,
is_win,
PEP8 for rest of pylib (#1451) * PEP8 dbproxy.py * PEP8 errors.py * PEP8 httpclient.py * PEP8 lang.py * PEP8 latex.py * Add decorator to deprectate key words * Make replacement for deprecated attribute optional * Use new helper `_print_replacement_warning()` * PEP8 media.py * PEP8 rsbackend.py * PEP8 sound.py * PEP8 stdmodels.py * PEP8 storage.py * PEP8 sync.py * PEP8 tags.py * PEP8 template.py * PEP8 types.py * Fix DeprecatedNamesMixinForModule The class methods need to be overridden with instance methods, so every module has its own dicts. * Use `# pylint: disable=invalid-name` instead of id * PEP8 utils.py * Only decorate `__getattr__` with `@no_type_check` * Fix mypy issue with snakecase Importing it from `anki._vendor` raises attribute errors. * Format * Remove inheritance of DeprecatedNamesMixin There's almost no shared code now and overriding classmethods with instance methods raises mypy issues. * Fix traceback frames of deprecation warnings * remove fn/TimedLog (dae) Neither Anki nor add-ons appear to have been using it * fix some issues with stringcase use (dae) - the wheel was depending on the PyPI version instead of our vendored version - _vendor:stringcase should not have been listed in the anki py_library. We already include the sources in py_srcs, and need to refer to them directly. By listing _vendor:stringcase as well, we were making a top-level stringcase library available, which would have only worked for distributing because the wheel definition was also incorrect. - mypy errors are what caused me to mistakenly add the above - they were because the type: ignore at the top of stringcase.py was causing mypy to completely ignore the file, so it was not aware of any attributes it contained.
2021-10-25 06:50:13 +02:00
no_bundled_libs,
version_with_build,
)
2019-12-20 10:19:03 +01:00
from aqt.qt import *
2021-05-20 10:26:18 +02:00
from aqt.theme import theme_manager
2019-12-20 10:19:03 +01:00
if TYPE_CHECKING:
2021-01-27 05:22:17 +01:00
TextFormat = Union[Literal["plain", "rich"]]
def aqt_data_folder() -> str:
# running in Bazel on macOS?
if path := os.getenv("AQT_DATA_FOLDER"):
return path
2021-10-28 10:46:45 +02:00
# packaged?
elif getattr(sys, "frozen", False):
path = os.path.join(sys.prefix, "lib/aqt/data")
if os.path.exists(path):
return path
else:
return os.path.join(sys.prefix, "../Resources/aqt/data")
elif os.path.exists(dir := os.path.join(os.path.dirname(__file__), "data")):
return os.path.abspath(dir)
else:
2021-10-28 10:46:45 +02:00
# should only happen when running unit tests
print("warning, data folder not found")
return "."
2021-03-26 04:48:26 +01:00
# shortcut to access Fluent translations; set as
tr = tr_legacyglobal
2021-07-23 12:17:20 +02:00
HelpPageArgument = Union["HelpPage.V", str]
def openHelp(section: HelpPageArgument) -> None:
2021-07-22 10:07:13 +02:00
if isinstance(section, str):
link = tr.backend().help_page_link(page=HelpPage.INDEX) + section
2021-07-22 10:07:13 +02:00
else:
link = tr.backend().help_page_link(page=section)
openLink(link)
2019-12-23 01:34:10 +01:00
def openLink(link: str | QUrl) -> None:
2021-03-26 04:48:26 +01:00
tooltip(tr.qt_misc_loading(), period=1000)
PEP8 for rest of pylib (#1451) * PEP8 dbproxy.py * PEP8 errors.py * PEP8 httpclient.py * PEP8 lang.py * PEP8 latex.py * Add decorator to deprectate key words * Make replacement for deprecated attribute optional * Use new helper `_print_replacement_warning()` * PEP8 media.py * PEP8 rsbackend.py * PEP8 sound.py * PEP8 stdmodels.py * PEP8 storage.py * PEP8 sync.py * PEP8 tags.py * PEP8 template.py * PEP8 types.py * Fix DeprecatedNamesMixinForModule The class methods need to be overridden with instance methods, so every module has its own dicts. * Use `# pylint: disable=invalid-name` instead of id * PEP8 utils.py * Only decorate `__getattr__` with `@no_type_check` * Fix mypy issue with snakecase Importing it from `anki._vendor` raises attribute errors. * Format * Remove inheritance of DeprecatedNamesMixin There's almost no shared code now and overriding classmethods with instance methods raises mypy issues. * Fix traceback frames of deprecation warnings * remove fn/TimedLog (dae) Neither Anki nor add-ons appear to have been using it * fix some issues with stringcase use (dae) - the wheel was depending on the PyPI version instead of our vendored version - _vendor:stringcase should not have been listed in the anki py_library. We already include the sources in py_srcs, and need to refer to them directly. By listing _vendor:stringcase as well, we were making a top-level stringcase library available, which would have only worked for distributing because the wheel definition was also incorrect. - mypy errors are what caused me to mistakenly add the above - they were because the type: ignore at the top of stringcase.py was causing mypy to completely ignore the file, so it was not aware of any attributes it contained.
2021-10-25 06:50:13 +02:00
with no_bundled_libs():
QDesktopServices.openUrl(QUrl(link))
2019-12-23 01:34:10 +01:00
2021-01-27 05:22:17 +01:00
def showWarning(
text: str,
parent: QWidget | None = None,
help: HelpPageArgument = "",
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)
2019-12-23 01:34:10 +01:00
2021-01-27 05:22:17 +01:00
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)
2019-12-23 01:34:10 +01:00
def showInfo(
text: str,
parent: QWidget | None = None,
help: HelpPageArgument = "",
type: str = "info",
title: str = "Anki",
textFormat: TextFormat | None = None,
customBtns: list[QMessageBox.StandardButton] | None = None,
2021-01-27 05:22:17 +01:00
) -> 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)
2019-02-27 05:16:35 +01:00
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)
b.setDefault(True)
if help:
b = mb.addButton(QMessageBox.StandardButton.Help)
qconnect(b.clicked, lambda: openHelp(help))
b.setAutoDefault(False)
return mb.exec()
2019-12-23 01:34:10 +01:00
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:
2019-12-23 01:34:10 +01:00
def onCopy() -> None:
QApplication.clipboard().setText(text.toPlainText())
2019-12-23 01:34:10 +01:00
2021-03-26 04:48:26 +01:00
btn = QPushButton(tr.qt_misc_copy_to_clipboard())
qconnect(btn.clicked, onCopy)
box.addButton(btn, QDialogButtonBox.ButtonRole.ActionRole)
2019-12-23 01:34:10 +01:00
def onReject() -> None:
if geomKey:
saveGeom(diag, geomKey)
QDialog.reject(diag)
2019-12-23 01:34:10 +01:00
qconnect(box.rejected, onReject)
2019-12-23 01:34:10 +01:00
def onFinish() -> None:
if geomKey:
saveGeom(diag, geomKey)
2019-12-23 01:34:10 +01:00
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
2019-12-23 01:34:10 +01:00
def askUser(
text: str,
parent: QWidget = None,
help: HelpPageArgument = 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:
openHelp(help)
else:
break
return r == QMessageBox.StandardButton.Yes
2019-12-23 01:34:10 +01:00
class ButtonedDialog(QMessageBox):
def __init__(
self,
text: str,
buttons: list[str],
parent: QWidget | None = None,
help: HelpPageArgument = None,
title: str = "Anki",
):
2019-03-04 06:59:53 +01:00
QMessageBox.__init__(self, parent)
self._buttons: list[QPushButton] = []
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)
2021-03-26 04:48:26 +01:00
buttons.append(tr.actions_help())
def run(self) -> str:
self.exec()
but = self.clickedButton().text()
if but == "Help":
# FIXME stop dialog closing?
openHelp(self.help)
txt = self.clickedButton().text()
# work around KDE 'helpfully' adding accelerators to button text of Qt apps
return txt.replace("&", "")
def setDefault(self, idx: int) -> None:
2020-08-02 02:25:48 +02:00
self.setDefaultButton(self._buttons[idx])
2019-12-23 01:34:10 +01:00
def askUserDialog(
text: str,
buttons: list[str],
parent: QWidget | None = None,
help: HelpPageArgument = None,
title: str = "Anki",
) -> ButtonedDialog:
if not parent:
parent = aqt.mw
diag = ButtonedDialog(text, buttons, parent, help, title=title)
return diag
2019-12-23 01:34:10 +01:00
class GetTextDialog(QDialog):
def __init__(
self,
parent: QWidget | None,
question: str,
help: HelpPageArgument = 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
2020-08-02 02:25:48 +02:00
b = QDialogButtonBox(buts) # type: ignore
v.addWidget(b)
self.setLayout(v)
qconnect(b.button(QDialogButtonBox.StandardButton.Ok).clicked, self.accept)
qconnect(b.button(QDialogButtonBox.StandardButton.Cancel).clicked, self.reject)
if help:
qconnect(
b.button(QDialogButtonBox.StandardButton.Help).clicked,
self.helpRequested,
)
def accept(self) -> None:
return QDialog.accept(self)
def reject(self) -> None:
return QDialog.reject(self)
def helpRequested(self) -> None:
openHelp(self.help)
2019-12-23 01:34:10 +01:00
def getText(
prompt: str,
parent: QWidget | None = None,
help: HelpPageArgument = 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
2019-12-23 01:34:10 +01:00
d = GetTextDialog(
parent, prompt, help=help, edit=edit, default=default, title=title, **kwargs
)
d.setWindowModality(Qt.WindowModality.WindowModal)
2017-05-03 10:55:24 +02:00
if geomKey:
restoreGeom(d, geomKey)
ret = d.exec()
2017-05-03 10:55:24 +02:00
if geomKey and ret:
saveGeom(d, geomKey)
return (str(d.l.text()), ret)
2019-12-23 01:34:10 +01:00
def getOnlyText(*args: Any, **kwargs: Any) -> str:
(s, r) = getText(*args, **kwargs)
if r:
return s
else:
return ""
2019-12-23 01:34:10 +01:00
# 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
) -> 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()
2019-12-23 01:34:10 +01:00
def getTag(
parent: QWidget, deck: Collection, question: str, **kwargs: Any
) -> tuple[str, int]:
from aqt.tagedit import TagEdit
2019-12-23 01:34:10 +01:00
te = TagEdit(parent)
te.setCol(deck)
2019-12-23 01:34:10 +01:00
ret = getText(question, parent, edit=te, geomKey="getTag", **kwargs)
te.hideCompleter()
return ret
2019-12-23 01:34:10 +01:00
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
######################################################################
2019-12-23 01:34:10 +01:00
def getFile(
parent: QWidget,
title: str,
2021-02-02 14:30:53 +01:00
# 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:
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)
if os.path.exists(dir):
d.setDirectory(dir)
d.setWindowTitle(title)
d.setNameFilter(filter)
ret = []
2019-12-23 01:34:10 +01:00
def accept() -> None:
files = list(d.selectedFiles())
if dirkey:
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)
2019-12-23 01:34:10 +01:00
qconnect(d.accepted, accept)
2018-07-23 05:57:17 +02:00
if key:
restoreState(d, key)
d.exec()
2018-07-23 05:57:17 +02:00
if key:
saveState(d, key)
return ret[0] if ret else None
2019-12-23 01:34:10 +01:00
def getSaveFile(
parent: QDialog,
title: str,
dir_description: str,
key: str,
ext: str,
fname: str | None = None,
) -> str:
"""Ask the user for a file to save. Use DIR_DESCRIPTION as config
variable. The file dialog will default to open with FNAME."""
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(
2019-12-23 01:34:10 +01:00
parent,
title,
path,
f"{key} (*{ext})",
options=QFileDialog.Option.DontConfirmOverwrite,
2019-12-23 01:34:10 +01:00
)[0]
if file:
# 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):
2021-03-26 04:48:26 +01:00
if not askUser(tr.qt_misc_this_file_exists_are_you_sure(), parent):
return None
return file
2019-12-23 01:34:10 +01:00
def saveGeom(widget: QWidget, key: str) -> None:
# restoring a fullscreen window is buggy
# (at the time of writing; Qt 6.2.2 and 5.15)
if not widget.isFullScreen():
aqt.mw.pm.profile[f"{key}Geom"] = widget.saveGeometry()
2019-12-23 01:34:10 +01:00
def restoreGeom(
widget: QWidget, key: str, offset: int | None = None, adjustSize: bool = False
) -> None:
key += "Geom"
if aqt.mw.pm.profile.get(key):
widget.restoreGeometry(aqt.mw.pm.profile[key])
if is_mac and offset:
if qtmajor > 5 or qtminor > 6:
# bug in osx toolkit
s = widget.size()
2019-12-23 01:34:10 +01:00
widget.resize(s.width(), s.height() + offset * 2)
ensureWidgetInScreenBoundaries(widget)
else:
if adjustSize:
widget.adjustSize()
2019-12-23 01:34:10 +01:00
def ensureWidgetInScreenBoundaries(widget: QWidget) -> None:
handle = widget.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
geom = handle.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
2019-12-23 01:34:10 +01:00
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)
2019-12-23 01:34:10 +01:00
def saveState(widget: QFileDialog | QMainWindow, key: str) -> None:
key += "State"
aqt.mw.pm.profile[key] = widget.saveState()
2019-12-23 01:34:10 +01:00
def restoreState(widget: QFileDialog | QMainWindow, key: str) -> None:
key += "State"
if aqt.mw.pm.profile.get(key):
widget.restoreState(aqt.mw.pm.profile[key])
2019-12-23 01:34:10 +01:00
def saveSplitter(widget: QSplitter, key: str) -> None:
key += "Splitter"
aqt.mw.pm.profile[key] = widget.saveState()
def restoreSplitter(widget: QSplitter, key: str) -> None:
key += "Splitter"
if aqt.mw.pm.profile.get(key):
widget.restoreState(aqt.mw.pm.profile[key])
def _header_key(key: str) -> str:
# not compatible across major versions
qt_suffix = f"Qt{qtmajor}" if qtmajor > 5 else ""
return f"{key}Header{qt_suffix}"
def saveHeader(widget: QHeaderView, key: str) -> None:
aqt.mw.pm.profile[_header_key(key)] = widget.saveState()
def restoreHeader(widget: QHeaderView, key: str) -> None:
if state := aqt.mw.pm.profile.get(_header_key(key)):
widget.restoreState(state)
def save_is_checked(widget: QCheckBox, key: str) -> None:
key += "IsChecked"
aqt.mw.pm.profile[key] = widget.isChecked()
def restore_is_checked(widget: QCheckBox, key: str) -> 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:
name += "BoxHistory"
text_input = comboBox.lineEdit().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]:
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]:
comboBox.lineEdit().setText(session_input)
comboBox.lineEdit().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
2019-12-23 01:34:10 +01:00
def openFolder(path: str) -> None:
if is_win:
subprocess.run(["explorer", f"file://{path}"], check=False)
else:
PEP8 for rest of pylib (#1451) * PEP8 dbproxy.py * PEP8 errors.py * PEP8 httpclient.py * PEP8 lang.py * PEP8 latex.py * Add decorator to deprectate key words * Make replacement for deprecated attribute optional * Use new helper `_print_replacement_warning()` * PEP8 media.py * PEP8 rsbackend.py * PEP8 sound.py * PEP8 stdmodels.py * PEP8 storage.py * PEP8 sync.py * PEP8 tags.py * PEP8 template.py * PEP8 types.py * Fix DeprecatedNamesMixinForModule The class methods need to be overridden with instance methods, so every module has its own dicts. * Use `# pylint: disable=invalid-name` instead of id * PEP8 utils.py * Only decorate `__getattr__` with `@no_type_check` * Fix mypy issue with snakecase Importing it from `anki._vendor` raises attribute errors. * Format * Remove inheritance of DeprecatedNamesMixin There's almost no shared code now and overriding classmethods with instance methods raises mypy issues. * Fix traceback frames of deprecation warnings * remove fn/TimedLog (dae) Neither Anki nor add-ons appear to have been using it * fix some issues with stringcase use (dae) - the wheel was depending on the PyPI version instead of our vendored version - _vendor:stringcase should not have been listed in the anki py_library. We already include the sources in py_srcs, and need to refer to them directly. By listing _vendor:stringcase as well, we were making a top-level stringcase library available, which would have only worked for distributing because the wheel definition was also incorrect. - mypy errors are what caused me to mistakenly add the above - they were because the type: ignore at the top of stringcase.py was causing mypy to completely ignore the file, so it was not aware of any attributes it contained.
2021-10-25 06:50:13 +02:00
with no_bundled_libs():
QDesktopServices.openUrl(QUrl(f"file://{path}"))
2019-12-23 01:34:10 +01:00
def shortcut(key: str) -> str:
if is_mac:
return re.sub("(?i)ctrl", "Command", key)
return key
2019-12-23 01:34:10 +01:00
def maybeHideClose(bbox: QDialogButtonBox) -> None:
if is_mac:
b = bbox.button(QDialogButtonBox.StandardButton.Close)
if b:
bbox.removeButton(b)
2019-12-23 01:34:10 +01:00
def addCloseShortcut(widg: QDialog) -> None:
if not is_mac:
return
shortcut = QShortcut(QKeySequence("Ctrl+W"), widg)
qconnect(shortcut.activated, widg.reject)
setattr(widg, "_closeShortcut", shortcut)
2019-12-23 01:34:10 +01:00
def downArrow() -> str:
if is_win:
return ""
# windows 10 is lacking the smaller arrow on English installs
return ""
2019-12-23 01:34:10 +01:00
def current_window() -> QWidget | None:
if widget := QApplication.focusWidget():
return widget.window()
else:
return None
# Tooltips
######################################################################
_tooltipTimer: QTimer | None = None
_tooltipLabel: QLabel | None = None
2019-12-23 01:34:10 +01:00
def tooltip(
msg: str,
period: int = 3000,
parent: QWidget | None = None,
x_offset: int = 0,
y_offset: int = 100,
) -> None:
global _tooltipTimer, _tooltipLabel
2019-12-23 01:34:10 +01:00
class CustomLabel(QLabel):
silentlyClose = True
2019-12-23 01:34:10 +01:00
def mousePressEvent(self, evt: QMouseEvent) -> None:
evt.accept()
self.hide()
2019-12-23 01:34:10 +01:00
closeTooltip()
aw = parent or aqt.mw.app.activeWindow() or aqt.mw
2019-12-23 01:34:10 +01:00
lab = CustomLabel(
f"""<table cellpadding=10>
<tr>
<td>{msg}</td>
</tr>
</table>""",
2019-12-23 01:34:10 +01:00
aw,
)
lab.setFrameStyle(QFrame.Shape.Panel)
lab.setLineWidth(2)
lab.setWindowFlags(Qt.WindowType.ToolTip)
2020-01-23 22:55:14 +01:00
if not theme_manager.night_mode:
p = QPalette()
p.setColor(QPalette.ColorRole.Window, QColor("#feffc4"))
p.setColor(QPalette.ColorRole.WindowText, QColor("#000000"))
2020-01-23 22:55:14 +01:00
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
2019-12-23 01:34:10 +01:00
)
_tooltipLabel = lab
2019-12-23 01:34:10 +01:00
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
2019-12-23 01:34:10 +01:00
# true if invalid; print warning
def checkInvalidFilename(str: str, dirsep: bool = True) -> bool:
PEP8 for rest of pylib (#1451) * PEP8 dbproxy.py * PEP8 errors.py * PEP8 httpclient.py * PEP8 lang.py * PEP8 latex.py * Add decorator to deprectate key words * Make replacement for deprecated attribute optional * Use new helper `_print_replacement_warning()` * PEP8 media.py * PEP8 rsbackend.py * PEP8 sound.py * PEP8 stdmodels.py * PEP8 storage.py * PEP8 sync.py * PEP8 tags.py * PEP8 template.py * PEP8 types.py * Fix DeprecatedNamesMixinForModule The class methods need to be overridden with instance methods, so every module has its own dicts. * Use `# pylint: disable=invalid-name` instead of id * PEP8 utils.py * Only decorate `__getattr__` with `@no_type_check` * Fix mypy issue with snakecase Importing it from `anki._vendor` raises attribute errors. * Format * Remove inheritance of DeprecatedNamesMixin There's almost no shared code now and overriding classmethods with instance methods raises mypy issues. * Fix traceback frames of deprecation warnings * remove fn/TimedLog (dae) Neither Anki nor add-ons appear to have been using it * fix some issues with stringcase use (dae) - the wheel was depending on the PyPI version instead of our vendored version - _vendor:stringcase should not have been listed in the anki py_library. We already include the sources in py_srcs, and need to refer to them directly. By listing _vendor:stringcase as well, we were making a top-level stringcase library available, which would have only worked for distributing because the wheel definition was also incorrect. - mypy errors are what caused me to mistakenly add the above - they were because the type: ignore at the top of stringcase.py was causing mypy to completely ignore the file, so it was not aware of any attributes it contained.
2021-10-25 06:50:13 +02:00
bad = invalid_filename(str, dirsep)
if bad:
showWarning(tr.qt_misc_the_following_character_can_not_be(val=bad))
return True
return False
2019-12-23 01:34:10 +01:00
# Menus
######################################################################
# This code will be removed in the future, please don't rely on it.
MenuListChild = Union["SubMenu", QAction, "MenuItem", "MenuList"]
2019-12-23 01:34:10 +01:00
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] = []
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)))
2019-12-23 01:34:10 +01:00
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)
super().renderTo(submenu)
2019-12-23 01:34:10 +01:00
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)
qconnect(a.triggered, self.func)
2019-12-23 01:34:10 +01:00
def qtMenuShortcutWorkaround(qmenu: QMenu) -> None:
for act in qmenu.actions():
act.setShortcutVisibleInContextMenu(True)
2019-12-23 01:34:10 +01:00
2018-12-13 11:59:06 +01:00
######################################################################
2019-12-23 01:34:10 +01:00
def disallow_full_screen() -> bool:
"""Test for OpenGl on Windows, which is known to cause issues with full screen mode.
On Qt6, the driver is not detectable, so check if it has been set explicitly.
"""
from aqt import mw
from aqt.profiles import VideoDriver
return is_win and (
(qtmajor == 5 and mw.pm.video_driver() == VideoDriver.OpenGL)
or (
qtmajor == 6
and not os.environ.get("ANKI_SOFTWAREOPENGL")
and os.environ.get("QT_OPENGL") != "software"
)
)
def add_ellipsis_to_action_label(*actions: QAction) -> 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
2020-02-03 02:17:10 +01:00
import time
from aqt import mw
if is_win:
platname = f"Windows {platform.win32_ver()[0]}"
elif is_mac:
platname = f"Mac {platform.mac_ver()[0]}"
else:
platname = "Linux"
def schedVer() -> str:
try:
2021-08-20 02:47:41 +02:00
if mw.col.v3_scheduler():
return "3"
else:
return str(mw.col.sched_ver())
except:
return "?"
2020-02-03 02:17:10 +01:00
lc = mw.pm.last_addon_update_check()
lcfmt = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(lc))
return """\
Anki {} Python {} Qt {} PyQt {}
Platform: {}
Flags: frz={} ao={} sv={}
2020-02-03 02:17:10 +01:00
Add-ons, last update check: {}
2019-12-23 01:34:10 +01:00
""".format(
PEP8 for rest of pylib (#1451) * PEP8 dbproxy.py * PEP8 errors.py * PEP8 httpclient.py * PEP8 lang.py * PEP8 latex.py * Add decorator to deprectate key words * Make replacement for deprecated attribute optional * Use new helper `_print_replacement_warning()` * PEP8 media.py * PEP8 rsbackend.py * PEP8 sound.py * PEP8 stdmodels.py * PEP8 storage.py * PEP8 sync.py * PEP8 tags.py * PEP8 template.py * PEP8 types.py * Fix DeprecatedNamesMixinForModule The class methods need to be overridden with instance methods, so every module has its own dicts. * Use `# pylint: disable=invalid-name` instead of id * PEP8 utils.py * Only decorate `__getattr__` with `@no_type_check` * Fix mypy issue with snakecase Importing it from `anki._vendor` raises attribute errors. * Format * Remove inheritance of DeprecatedNamesMixin There's almost no shared code now and overriding classmethods with instance methods raises mypy issues. * Fix traceback frames of deprecation warnings * remove fn/TimedLog (dae) Neither Anki nor add-ons appear to have been using it * fix some issues with stringcase use (dae) - the wheel was depending on the PyPI version instead of our vendored version - _vendor:stringcase should not have been listed in the anki py_library. We already include the sources in py_srcs, and need to refer to them directly. By listing _vendor:stringcase as well, we were making a top-level stringcase library available, which would have only worked for distributing because the wheel definition was also incorrect. - mypy errors are what caused me to mistakenly add the above - they were because the type: ignore at the top of stringcase.py was causing mypy to completely ignore the file, so it was not aware of any attributes it contained.
2021-10-25 06:50:13 +02:00
version_with_build(),
2019-12-23 01:34:10 +01:00
platform.python_version(),
QT_VERSION_STR,
PYQT_VERSION_STR,
platname,
getattr(sys, "frozen", False),
mw.addonManager.dirty,
schedVer(),
2020-02-03 02:17:10 +01:00
lcfmt,
2019-12-23 01:34:10 +01:00
)
2018-12-18 10:29:34 +01:00
######################################################################
# adapted from version detection in qutebrowser
def opengl_vendor() -> str | None:
if qtmajor != 5:
return "unknown"
2018-12-18 10:29:34 +01:00
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 # pylint: disable=undefined-variable
2018-12-18 10:29:34 +01:00
vp.setVersion(2, 0)
try:
vf = ctx.versionFunctions(vp) # type: ignore
2018-12-18 10:29:34 +01:00
except ImportError as e:
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)
2019-12-23 01:34:10 +01:00
def gfxDriverIsBroken() -> bool:
2018-12-18 10:29:34 +01:00
driver = opengl_vendor()
return driver == "nouveau"
######################################################################
def startup_info() -> Any:
"Use subprocess.Popen(startupinfo=...) to avoid opening a console window."
if not 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
2021-04-26 08:46:08 +02:00
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.
"""
2021-04-26 08:46:08 +02:00
return pyqtSlot()(func) # type: ignore
2022-01-20 02:56:12 +01:00
def is_gesture_or_zoom_event(evt: QEvent) -> bool:
"""If the event is a gesture and/or will trigger zoom.
2022-01-20 02:56:12 +01:00
Includes zoom by pinching, and Ctrl-scrolling on Win and Linux.
"""
return isinstance(evt, QNativeGestureEvent) or (
isinstance(evt, QWheelEvent)
2022-01-20 02:56:12 +01:00
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())
@no_type_check
def __getattr__(name: str) -> Any:
return _deprecated_names.__getattr__(name)