From b55161cd39f66c0e2fbdbc0f21b3e946ea0d0168 Mon Sep 17 00:00:00 2001 From: RumovZ Date: Wed, 15 Mar 2023 06:29:05 +0100 Subject: [PATCH] Improve debug console (#2435) * List actions and locals in debug console * Ignore whitespace when wrapping line with pp * Scroll down after printing in debug console Was previously preserving relative vertical position. * Add feature to open and save debug scripts * Refactor debug console into own module * Add buffers to switch scripts * Add action to delete script --- qt/aqt/debug_console.py | 322 ++++++++++++++++++++++++++++++++++++++++ qt/aqt/forms/debug.ui | 29 +++- qt/aqt/main.py | 173 +-------------------- qt/aqt/widgetgallery.py | 12 +- 4 files changed, 357 insertions(+), 179 deletions(-) create mode 100644 qt/aqt/debug_console.py diff --git a/qt/aqt/debug_console.py b/qt/aqt/debug_console.py new file mode 100644 index 000000000..f22400782 --- /dev/null +++ b/qt/aqt/debug_console.py @@ -0,0 +1,322 @@ +# 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() + card = self._debugCard + bcard = self._debugBrowserCard + mw = aqt.mw + pp = pprint.pprint + self._captureOutput(True) + try: + # pylint: disable=exec-used + exec(text) + 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 "") + 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 diff --git a/qt/aqt/forms/debug.ui b/qt/aqt/forms/debug.ui index 898a42329..14b845589 100644 --- a/qt/aqt/forms/debug.ui +++ b/qt/aqt/forms/debug.ui @@ -6,7 +6,7 @@ 0 0 - 643 + 637 582 @@ -14,10 +14,20 @@ qt_misc_debug_console - + :/icons/anki.png:/icons/anki.png + + + + + + + -1 + + + @@ -49,7 +59,20 @@ QPlainTextEdit::NoWrap - Type commands here (Enter to submit) + Actions: + Ctrl+Enter Execute + Ctrl+Shift+Enter Execute and print current line + Ctrl+L Clear log + Ctrl+Shift+L Clear input + Ctrl+S Save script + Ctrl+O Open script + Ctrl+D Delete script + +Locals: + mw: AnkiQt Main window + card: Callable[[], Card | None] Reviewer card + bcard: Callable[[], Card | None] Browser card + pp: Callable[[object], None] Pretty print diff --git a/qt/aqt/main.py b/qt/aqt/main.py index 1acac47d3..2c0023d9a 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -11,7 +11,7 @@ import signal import weakref from argparse import Namespace from concurrent.futures import Future -from typing import Any, Literal, Sequence, TextIO, TypeVar, cast +from typing import Any, Literal, Sequence, TypeVar, cast import anki import anki.cards @@ -46,6 +46,7 @@ from anki.utils import ( from aqt import gui_hooks from aqt.addons import DownloadLogEntry, check_and_prompt_for_updates, show_log_to_user from aqt.dbcheck import check_db +from aqt.debug_console import show_debug_console from aqt.emptycards import show_empty_cards from aqt.flags import FlagManager from aqt.import_export.exporting import ExportDialog @@ -74,17 +75,14 @@ from aqt.utils import ( askUser, checkInvalidFilename, current_window, - disable_help_button, disallow_full_screen, getFile, getOnlyText, openHelp, openLink, restoreGeom, - restoreSplitter, restoreState, saveGeom, - saveSplitter, saveState, showInfo, showWarning, @@ -1102,7 +1100,7 @@ title="{}" {}>{}""".format( def setupKeys(self) -> None: globalShortcuts = [ - ("Ctrl+:", self.onDebug), + ("Ctrl+:", show_debug_console), ("d", lambda: self.moveToState("deckBrowser")), ("s", self.onStudyKey), ("a", self.onAddCard), @@ -1631,171 +1629,6 @@ title="{}" {}>{}""".format( def onEmptyCards(self) -> None: show_empty_cards(self) - # Debugging - ###################################################################### - - def onDebug(self) -> None: - frm = self.debug_diag_form = aqt.forms.debug.Ui_Dialog() - - class DebugDialog(QDialog): - silentlyClose = True - - def reject(self) -> None: - super().reject() - saveSplitter(frm.splitter, "DebugConsoleWindow") - saveGeom(self, "DebugConsoleWindow") - - d = self.debugDiag = DebugDialog() - disable_help_button(d) - frm.setupUi(d) - restoreGeom(d, "DebugConsoleWindow") - restoreSplitter(frm.splitter, "DebugConsoleWindow") - font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont) - font.setPointSize(frm.text.font().pointSize() + 1) - frm.text.setFont(font) - frm.log.setFont(font) - s = self.debugDiagShort = QShortcut(QKeySequence("ctrl+return"), d) - qconnect(s.activated, lambda: self.onDebugRet(frm)) - s = self.debugDiagShort = QShortcut(QKeySequence("ctrl+shift+return"), d) - qconnect(s.activated, lambda: self.onDebugPrint(frm)) - s = self.debugDiagShort = QShortcut(QKeySequence("ctrl+l"), d) - qconnect(s.activated, frm.log.clear) - s = self.debugDiagShort = QShortcut(QKeySequence("ctrl+shift+l"), d) - qconnect(s.activated, frm.text.clear) - - qconnect(frm.widgetsButton.clicked, self._on_widgetGallery) - - def addContextMenu( - ev: Union[QCloseEvent, QContextMenuEvent], name: str - ) -> None: - ev.accept() - menu = frm.log.createStandardContextMenu(QCursor.pos()) - menu.addSeparator() - if name == "log": - a = menu.addAction("Clear Log") - a.setShortcut(QKeySequence("ctrl+l")) - qconnect(a.triggered, frm.log.clear) - elif name == "text": - a = menu.addAction("Clear Code") - a.setShortcut(QKeySequence("ctrl+shift+l")) - qconnect(a.triggered, frm.text.clear) - menu.exec(QCursor.pos()) - - frm.log.contextMenuEvent = lambda ev: addContextMenu(ev, "log") # type: ignore[assignment] - frm.text.contextMenuEvent = lambda ev: addContextMenu(ev, "text") # type: ignore[assignment] - gui_hooks.debug_console_will_show(d) - d.show() - - 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: - card = self.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, frm: aqt.forms.debug.Ui_Dialog) -> None: - cursor = frm.text.textCursor() - position = cursor.position() - cursor.select(QTextCursor.SelectionType.LineUnderCursor) - line = cursor.selectedText() - pfx, sfx = "pp(", ")" - if not line.startswith(pfx): - line = f"{pfx}{line}{sfx}" - cursor.insertText(line) - cursor.setPosition(position + len(pfx)) - frm.text.setTextCursor(cursor) - self.onDebugRet(frm) - - def onDebugRet(self, frm: aqt.forms.debug.Ui_Dialog) -> None: - import pprint - import traceback - - text = frm.text.toPlainText() - card = self._debugCard - bcard = self._debugBrowserCard - mw = self - pp = pprint.pprint - self._captureOutput(True) - try: - # pylint: disable=exec-used - exec(text) - 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 "") - to_append = gui_hooks.debug_console_did_evaluate_python( - to_append, text, frm - ) - frm.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, frm - ) - frm.log.appendPlainText(to_append) - frm.log.ensureCursorVisible() - # System specific code ########################################################################## diff --git a/qt/aqt/widgetgallery.py b/qt/aqt/widgetgallery.py index 16011b967..19b922839 100644 --- a/qt/aqt/widgetgallery.py +++ b/qt/aqt/widgetgallery.py @@ -3,7 +3,7 @@ import aqt import aqt.main -from aqt.qt import QDialog, qconnect +from aqt.qt import QDialog, QWidget, qconnect from aqt.theme import WidgetStyle from aqt.utils import restoreGeom, saveGeom @@ -11,9 +11,9 @@ from aqt.utils import restoreGeom, saveGeom class WidgetGallery(QDialog): silentlyClose = True - def __init__(self, mw: aqt.main.AnkiQt) -> None: - super().__init__(mw) - self.mw = mw.weakref() + def __init__(self, parent: QWidget) -> None: + assert aqt.mw + super().__init__(parent) self.form = aqt.forms.widgets.Ui_Dialog() self.form.setupUi(self) @@ -29,10 +29,10 @@ class WidgetGallery(QDialog): self.form.styleComboBox.addItems( [member.name.lower().capitalize() for member in WidgetStyle] ) - self.form.styleComboBox.setCurrentIndex(self.mw.pm.get_widget_style()) + self.form.styleComboBox.setCurrentIndex(aqt.mw.pm.get_widget_style()) qconnect( self.form.styleComboBox.currentIndexChanged, - self.mw.pm.set_widget_style, + aqt.mw.pm.set_widget_style, ) def reject(self) -> None: