anki/qt/aqt/debug_console.py

325 lines
10 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 os
from dataclasses import dataclass
from functools import partial
from pathlib import Path
from typing import TextIO, cast
import anki.cards
import aqt
import aqt.forms
from aqt import gui_hooks
from aqt.profiles import ProfileManager
from aqt.qt import *
from aqt.utils import (
disable_help_button,
restoreGeom,
restoreSplitter,
saveGeom,
saveSplitter,
send_to_trash,
tr,
)
def show_debug_console() -> None:
assert aqt.mw
console = DebugConsole(aqt.mw)
gui_hooks.debug_console_will_show(console)
console.show()
SCRIPT_FOLDER = "debug_scripts"
UNSAVED_SCRIPT = "Unsaved script"
@dataclass
class Action:
name: str
shortcut: str
action: Callable[[], None]
class DebugConsole(QDialog):
silentlyClose = True
_last_index = 0
def __init__(self, parent: QWidget) -> None:
self._buffers: dict[int, str] = {}
super().__init__(parent)
self._setup_ui()
disable_help_button(self)
restoreGeom(self, "DebugConsoleWindow")
restoreSplitter(self.frm.splitter, "DebugConsoleWindow")
def _setup_ui(self):
self.frm = aqt.forms.debug.Ui_Dialog()
self.frm.setupUi(self)
self._text: QPlainTextEdit = self.frm.text
self._log: QPlainTextEdit = self.frm.log
self._script: QComboBox = self.frm.script
self._setup_text_edits()
self._setup_scripts()
self._setup_actions()
self._setup_context_menu()
qconnect(self.frm.widgetsButton.clicked, self._on_widgetGallery)
qconnect(self._script.currentIndexChanged, self._on_script_change)
def _setup_text_edits(self):
font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
font.setPointSize(self._text.font().pointSize() + 1)
self._text.setFont(font)
self._log.setFont(font)
def _setup_scripts(self) -> None:
self._dir = ProfileManager.get_created_base_folder(None).joinpath(SCRIPT_FOLDER)
self._dir.mkdir(exist_ok=True)
self._script.addItem(UNSAVED_SCRIPT)
self._script.addItems(os.listdir(self._dir))
def _setup_actions(self) -> None:
for action in self._actions():
qconnect(
QShortcut(QKeySequence(action.shortcut), self).activated, action.action
)
def _actions(self):
return [
Action("Execute", "ctrl+return", self.onDebugRet),
Action("Execute and print", "ctrl+shift+return", self.onDebugPrint),
Action("Clear log", "ctrl+l", self._log.clear),
Action("Clear code", "ctrl+shift+l", self._text.clear),
Action("Save script", "ctrl+s", self._save_script),
Action("Open script", "ctrl+o", self._open_script),
Action("Delete script", "ctrl+d", self._delete_script),
]
def reject(self) -> None:
super().reject()
saveSplitter(self.frm.splitter, "DebugConsoleWindow")
saveGeom(self, "DebugConsoleWindow")
def _on_script_change(self, new_index: int) -> None:
self._buffers[self._last_index] = self._text.toPlainText()
self._text.setPlainText(self._get_script(new_index) or "")
self._last_index = new_index
def _get_script(self, idx: int) -> str | None:
if script := self._buffers.get(idx, ""):
return script
if path := self._get_item(idx):
return path.read_text(encoding="utf8")
return None
def _get_item(self, idx: int) -> Path | None:
if not idx:
return None
path = Path(self._script.itemText(idx))
return path if path.is_absolute() else self._dir.joinpath(path)
def _get_index(self, path: Path) -> int:
return self._script.findText(self._path_to_item(path))
def _path_to_item(self, path: Path) -> str:
return path.name if path.is_relative_to(self._dir) else str(path)
def _current_script_path(self) -> Path | None:
return self._get_item(self._script.currentIndex())
def _save_script(self) -> None:
if not (path := self._current_script_path()):
new_file = QFileDialog.getSaveFileName(
self, directory=str(self._dir), filter="Python file (*.py)"
)[0]
if not new_file:
return
path = Path(new_file)
path.write_text(self._text.toPlainText(), encoding="utf8")
item = self._path_to_item(path)
if (idx := self._get_index(path)) == -1:
self._script.addItem(item)
idx = self._script.count() - 1
# update existing buffer, so text edit doesn't change when index changes
self._buffers[idx] = self._text.toPlainText()
self._script.setCurrentIndex(idx)
def _open_script(self) -> None:
file = QFileDialog.getOpenFileName(
self, directory=str(self._dir), filter="Python file (*.py)"
)[0]
if not file:
return
path = Path(file)
item = self._path_to_item(path)
if (idx := self._get_index(path)) == -1:
self._script.addItem(item)
idx = self._script.count() - 1
elif idx in self._buffers:
del self._buffers[idx]
if idx == self._script.currentIndex():
self._text.setPlainText(path.read_text(encoding="utf8"))
else:
self._script.setCurrentIndex(idx)
def _delete_script(self) -> None:
if not (path := self._current_script_path()):
return
send_to_trash(path)
deleted_idx = self._script.currentIndex()
self._script.setCurrentIndex(0)
self._script.removeItem(deleted_idx)
self._drop_buffer_and_shift_keys(deleted_idx)
def _drop_buffer_and_shift_keys(self, idx: int) -> None:
def shift(old_idx: int) -> int:
return old_idx - 1 if old_idx > idx else old_idx
self._buffers = {shift(i): val for i, val in self._buffers.items() if i != idx}
def _setup_context_menu(self) -> None:
for text_edit in (self._log, self._text):
text_edit.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
qconnect(
text_edit.customContextMenuRequested,
partial(self._on_context_menu, text_edit),
)
def _on_context_menu(self, text_edit: QPlainTextEdit) -> None:
menu = text_edit.createStandardContextMenu()
menu.addSeparator()
for action in self._actions():
entry = menu.addAction(action.name)
entry.setShortcut(QKeySequence(action.shortcut))
qconnect(entry.triggered, action.action)
menu.exec(QCursor.pos())
def _on_widgetGallery(self) -> None:
from aqt.widgetgallery import WidgetGallery
self.widgetGallery = WidgetGallery(self)
self.widgetGallery.show()
def _captureOutput(self, on: bool) -> None:
mw2 = self
class Stream:
def write(self, data: str) -> None:
mw2._output += data
if on:
self._output = ""
self._oldStderr = sys.stderr
self._oldStdout = sys.stdout
s = cast(TextIO, Stream())
sys.stderr = s
sys.stdout = s
else:
sys.stderr = self._oldStderr
sys.stdout = self._oldStdout
def _card_repr(self, card: anki.cards.Card) -> None:
import copy
import pprint
if not card:
print("no card")
return
print("Front:", card.question())
print("\n")
print("Back:", card.answer())
print("\nNote:")
note = copy.copy(card.note())
for k, v in note.items():
print(f"- {k}:", v)
print("\n")
del note.fields
del note._fmap
pprint.pprint(note.__dict__)
print("\nCard:")
c = copy.copy(card)
c._render_output = None
pprint.pprint(c.__dict__)
def _debugCard(self) -> anki.cards.Card | None:
assert aqt.mw
card = aqt.mw.reviewer.card
self._card_repr(card)
return card
def _debugBrowserCard(self) -> anki.cards.Card | None:
card = aqt.dialogs._dialogs["Browser"][1].card
self._card_repr(card)
return card
def onDebugPrint(self) -> None:
cursor = self._text.textCursor()
position = cursor.position()
cursor.select(QTextCursor.SelectionType.LineUnderCursor)
line = cursor.selectedText()
whitespace, stripped = _split_off_leading_whitespace(line)
pfx, sfx = "pp(", ")"
if not stripped.startswith(pfx):
line = f"{whitespace}{pfx}{stripped}{sfx}"
cursor.insertText(line)
cursor.setPosition(position + len(pfx))
self._text.setTextCursor(cursor)
self.onDebugRet()
def onDebugRet(self) -> None:
import pprint
import traceback
text = self._text.toPlainText()
vars = {
"card": self._debugCard,
"bcard": self._debugBrowserCard,
"mw": aqt.mw,
"pp": pprint.pprint,
}
self._captureOutput(True)
try:
# pylint: disable=exec-used
exec(text, vars)
except:
self._output += traceback.format_exc()
self._captureOutput(False)
buf = ""
for c, line in enumerate(text.strip().split("\n")):
if c == 0:
buf += f">>> {line}\n"
else:
buf += f"... {line}\n"
try:
to_append = buf + (self._output or "<no output>")
to_append = gui_hooks.debug_console_did_evaluate_python(
to_append, text, self.frm
)
self._log.appendPlainText(to_append)
except UnicodeDecodeError:
to_append = tr.qt_misc_non_unicode_text()
to_append = gui_hooks.debug_console_did_evaluate_python(
to_append, text, self.frm
)
self._log.appendPlainText(to_append)
slider = self._log.verticalScrollBar()
slider.setValue(slider.maximum())
self._log.ensureCursorVisible()
def _split_off_leading_whitespace(text: str) -> tuple[str, str]:
stripped = text.lstrip()
whitespace = text[: len(text) - len(stripped)]
return whitespace, stripped