anki/qt/aqt/utils.py

1015 lines
28 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
import aqt
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,
isMac,
isWin,
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
# running in place?
dir = os.path.join(os.path.dirname(__file__), "data")
if os.path.exists(dir):
return dir
# packaged install?
if isMac:
dir2 = os.path.join(sys.prefix, "..", "Resources", "aqt_data")
else:
dir2 = os.path.join(sys.prefix, "aqt_data")
2020-11-01 05:38:13 +01:00
if os.path.exists(dir2):
return dir2
# 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."
assert not dir or not 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:
key += "Geom"
if isMac and (widget.windowState() & Qt.WindowState.WindowFullScreen):
geom = None
else:
geom = widget.saveGeometry()
aqt.mw.pm.profile[key] = geom
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 isMac 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)
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 isWin:
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 isMac:
return re.sub("(?i)ctrl", "Command", key)
return key
2019-12-23 01:34:10 +01:00
def maybeHideClose(bbox: QDialogButtonBox) -> None:
if isMac:
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 isMac:
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 isWin:
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(
2019-12-23 01:34:10 +01:00
period, closeTooltip, False, requiresCollection=False
)
_tooltipLabel = lab
2019-12-23 01:34:10 +01:00
def closeTooltip() -> None:
global _tooltipLabel, _tooltipTimer
if _tooltipLabel:
try:
_tooltipLabel.deleteLater()
except:
# already deleted as parent window closed
pass
_tooltipLabel = None
if _tooltipTimer:
_tooltipTimer.stop()
_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 supportText() -> str:
import platform
2020-02-03 02:17:10 +01:00
import time
from aqt import mw
if isWin:
platname = f"Windows {platform.win32_ver()[0]}"
elif isMac:
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
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)