diff --git a/ftl/core/preferences.ftl b/ftl/core/preferences.ftl index 23b72f267..81e6d0d2e 100644 --- a/ftl/core/preferences.ftl +++ b/ftl/core/preferences.ftl @@ -39,6 +39,10 @@ preferences-theme = Theme preferences-theme-follow-system = Follow System preferences-theme-light = Light preferences-theme-dark = Dark +preferences-reviewer-type = Reviewer Type +preferences-reviewer-type-default = Default +preferences-reviewer-type-flexible = Flexible +preferences-reviewer-show-reps-done-today = Show Number of Reviews done today preferences-v3-scheduler = V3 scheduler preferences-check-for-updates = Check for program updates preferences-ignore-accents-in-search = Ignore accents in search (slower) diff --git a/qt/aqt/deckbrowser.py b/qt/aqt/deckbrowser.py index ca754e783..c3ffb8070 100644 --- a/qt/aqt/deckbrowser.py +++ b/qt/aqt/deckbrowser.py @@ -3,6 +3,7 @@ from __future__ import annotations +import functools import html from copy import deepcopy from dataclasses import dataclass @@ -14,6 +15,7 @@ from anki.collection import Collection, OpChanges from anki.decks import DeckCollapseScope, DeckId, DeckTreeNode from aqt import AnkiQt, gui_hooks from aqt.deckoptions import display_options_for_deck_id +from aqt.flexible_grading_reviewer.widgets import FlexiblePushButton from aqt.operations import QueryOp from aqt.operations.deck import ( add_deck_dialog, @@ -436,3 +438,42 @@ class DeckBrowser: showInfo(tr.scheduling_update_done()) self.refresh() + + +class FlexibleDeckBrowser(DeckBrowser): + """ + Adds *Flexible Grading* features to Anki as a separate Reviewer. + The idea is that Anki can have many Reviewer classes, and the user can choose which they prefer. + + Initially, Flexible Grading was implemented as an add-on. + However, add-ons require patching every time Anki introduces a change that breaks add-on compatibility. + Thus, it proves better to add new features directly to Anki. + """ + + def add_bottom_buttons(self) -> None: + self.mw.bottomWidget.left_bucket.reset(is_visible=False) + self.mw.bottomWidget.right_bucket.reset(is_visible=False) + self.mw.bottomWidget.middle_bucket.reset(is_visible=True) + + draw_links = deepcopy(self.drawLinks) + pycmds = { + "shared": self._onShared, + "create": self._on_create, + "import": self.mw.onImport, + } + for keyboard_shortcut, pycmd, button_text in draw_links: + button = self.mw.bottomWidget.middle_bucket.add_button( + FlexiblePushButton(text=button_text), + on_clicked=functools.partial(pycmds[pycmd]), + ) + if keyboard_shortcut: + button.setToolTip( + tr.actions_shortcut_key(val=shortcut(keyboard_shortcut)) + ) + + def _clear_bottom_web(self) -> None: + self.bottom.web.setHtml("") + + def _drawButtons(self) -> None: + self._clear_bottom_web() + self.add_bottom_buttons() diff --git a/qt/aqt/flexible_grading_reviewer/__init__.py b/qt/aqt/flexible_grading_reviewer/__init__.py new file mode 100644 index 000000000..e3d8b5638 --- /dev/null +++ b/qt/aqt/flexible_grading_reviewer/__init__.py @@ -0,0 +1,2 @@ +# Copyright: Ankitects Pty Ltd and contributors +# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html diff --git a/qt/aqt/flexible_grading_reviewer/utils.py b/qt/aqt/flexible_grading_reviewer/utils.py new file mode 100644 index 000000000..a41a6c094 --- /dev/null +++ b/qt/aqt/flexible_grading_reviewer/utils.py @@ -0,0 +1,45 @@ +# 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 anki.collection +import aqt +from anki.consts import REVLOG_RESCHED +from aqt import tr +from aqt.qt import * + + +def ease_to_answer_key(ease: int) -> str: + return ( + tr.actions_shortcut_key(val=aqt.mw.pm.get_answer_key(ease)) + if aqt.mw.pm.get_answer_key(ease) + else "" + ) + + +def ease_to_answer_key_short(ease: int) -> str: + return ease_to_answer_key(ease).split(":")[1].strip() + + +def prev_day_cutoff_ms(col: anki.collection.Collection) -> int: + return (col.sched.day_cutoff - 86_400) * 1000 + + +def studied_today_count(col: anki.collection.Collection) -> int: + return col.db.scalar( + """ SELECT COUNT(*) FROM revlog WHERE type != ? AND id > ? """, + REVLOG_RESCHED, + prev_day_cutoff_ms(col), + ) + + +def clear_layout(layout: QLayout) -> None: + """Remove all widgets from a layout and delete them.""" + while layout.count(): + child = layout.takeAt(0) + if child.widget(): + widget = child.widget() + widget.setParent(None) + widget.deleteLater() + elif child.layout(): + clear_layout(child.layout()) diff --git a/qt/aqt/flexible_grading_reviewer/widgets.py b/qt/aqt/flexible_grading_reviewer/widgets.py new file mode 100644 index 000000000..ed86c3751 --- /dev/null +++ b/qt/aqt/flexible_grading_reviewer/widgets.py @@ -0,0 +1,186 @@ +# 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 aqt +from aqt import qconnect +from aqt.flexible_grading_reviewer.utils import clear_layout +from aqt.qt import * + + +class FlexiblePushButton(QPushButton): + _height: int = 16 + _font_size: int = _height - 4 + + def __init__( + self, + text="", + text_color: str = "#111111", + text_underline: bool = False, + parent=None, + ) -> None: + super().__init__(text, parent) + # Fixed height 16px, let width be flexible + self.setFixedHeight(self._height) + # Remove extra spacing from focus/contents margins + self.setContentsMargins(0, 0, 0, 0) + self.set_text_style(text_color, text_underline) + # Optional: ensure compact size hint + self.setSizePolicy( + self.sizePolicy().horizontalPolicy(), + self.sizePolicy().Policy.Fixed, + ) + + def set_text_style( + self, text_color: str = "#111111", text_underline: bool = False + ) -> None: + stylesheet = ( + """ + FlexiblePushButton { + border: none; + background: transparent; + color: TEXT_COLOR; + margin: 0; + padding: 0; + font-size: FONT_SIZEpx; + min-width: 0; + qproperty-flat: true; + font-family: "Noto Sans Mono", "Liberation Mono", "DejaVu Sans Mono", "Courier New", "Lucida Console", + Courier, Consolas, "Noto Sans Mono CJK JP", monospace; + TEXT_UNDERLINE + } + FlexiblePushButton:hover { + background: #d0d0d0; + color: #000; + } + FlexiblePushButton:pressed { + background: #b8b8b8; + } + """.replace("FONT_SIZE", f"{self._font_size}") + .replace("TEXT_COLOR", text_color) + .replace( + "TEXT_UNDERLINE", + "text-decoration: underline;" if text_underline else "", + ) + ) + self.setStyleSheet(stylesheet) + + def sizeHint(self) -> QSize: + """ + Ensure sizeHint respects fixed height and minimal width + """ + hint = super().sizeHint() + return QSize(max(hint.width(), 0), self._height) + + +class FlexibleHorizontalBar(QWidget): + """ + A simple bucket-like widget that holds other widgets and places them in a horizontal line. + """ + + _height: int = 16 + _spacing: int = 0 + + mw: aqt.AnkiQt + + def __init__(self, mw: aqt.AnkiQt) -> None: + super().__init__(mw) + self.mw = mw + # Setup Layout + self._layout = QHBoxLayout() + self.setLayout(self._layout) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(self._spacing) + self.setMaximumHeight(self._height) + + def add_stretch(self, stretch_value: int = 1) -> None: + return self._layout.addStretch(stretch_value) + + def add_widget(self, widget: QWidget) -> QWidget: + self._layout.addWidget(widget) + return widget + + def add_button(self, button: QPushButton, *, on_clicked: Callable) -> QPushButton: + self.add_widget(button) + qconnect(button.clicked, lambda button_checked=False: on_clicked()) + return button + + def clear_layout(self) -> None: + return clear_layout(self._layout) + + def reset(self, is_visible: bool) -> None: + """ + Prepare to show a new set of buttons. + """ + self.setHidden(not is_visible) + self.clear_layout() + + +class FlexibleButtonsList(FlexibleHorizontalBar): + _spacing: int = 8 + + +class FlexibleBottomBar(FlexibleHorizontalBar): + """ + Bottom bar. Shows answer buttons, answer timer, reps done today. + """ + + def __init__(self, mw: aqt.AnkiQt) -> None: + super().__init__(mw) + # Setup Buttons + self.left_bucket = FlexibleButtonsList(self.mw) + self.middle_bucket = FlexibleButtonsList(self.mw) + self.right_bucket = FlexibleButtonsList(self.mw) + # Setup UI + self._setup_ui() + + def _setup_ui(self) -> None: + self.add_widget(self.left_bucket) + self.add_stretch() + self.add_widget(self.middle_bucket) + self.add_stretch() + self.add_widget(self.right_bucket) + + +class FlexibleTimerLabel(QLabel): + def __init__(self, parent=None) -> None: + super().__init__(parent) + self._time = 0 # current time (seconds) + self._max_time = 0 # maximum time (seconds); 0 means unset + self._qtimer = QTimer(self) + self._qtimer.setInterval(1000) + qconnect(self._qtimer.timeout, self._on_tick) + self.setAlignment(Qt.AlignmentFlag.AlignCenter) + + def start(self, max_time: int) -> None: + if max_time <= 0: + raise ValueError("max time should be greater than 0") + self._max_time = max_time + self._time = 0 + self._update_display() + if self._qtimer.isActive(): + self._qtimer.stop() + self._qtimer.start() + + def stop(self) -> None: + if self._qtimer.isActive(): + self._qtimer.stop() + + def _on_tick(self) -> None: + self._time = min(self._time + 1, self._max_time) + self._update_display() + + def _update_display(self) -> None: + if self._max_time <= 0: + raise ValueError("max time should be greater than 0") + + t = min(self._max_time, self._time) + m, s = divmod(t, 60) + s_str = f"{s:02d}" + time_string = f"{m}:{s_str}" + + if t >= self._max_time > 0: + self.setText(f"{time_string}") + self.stop() + else: + self.setText(time_string) diff --git a/qt/aqt/forms/preferences.ui b/qt/aqt/forms/preferences.ui index 0035e1f42..942e25379 100644 --- a/qt/aqt/forms/preferences.ui +++ b/qt/aqt/forms/preferences.ui @@ -373,6 +373,26 @@ preferences_review + + + + + + + + + + + + 0 + 0 + + + + preferences_reviewer_show_reps_done_today + + + @@ -1267,6 +1287,7 @@ dayOffset lrnCutoff timeLimit + reviewerTypeComboBox showPlayButtons interrupt_audio showProgress diff --git a/qt/aqt/main.py b/qt/aqt/main.py index c707d1b2a..762d46453 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -53,6 +53,7 @@ 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.flexible_grading_reviewer.widgets import FlexibleBottomBar from aqt.import_export.exporting import ExportDialog from aqt.import_export.importing import ( import_collection_package_op, @@ -66,6 +67,7 @@ from aqt.operations import QueryOp from aqt.operations.collection import redo, undo from aqt.operations.deck import set_current_deck from aqt.profiles import ProfileManager as ProfileManagerType +from aqt.profiles import ReviewerType from aqt.qt import * from aqt.qt import sip from aqt.sync import sync_collection, sync_login @@ -182,6 +184,7 @@ class AnkiQt(QMainWindow): pm: ProfileManagerType web: MainWebView bottomWeb: BottomWebView + bottomWidget: FlexibleBottomBar def __init__( self, @@ -977,6 +980,10 @@ title="{}" {}>{}""".format( self.mainLayout.addWidget(sweb) self.form.centralwidget.setLayout(self.mainLayout) + if self.pm.reviewer() == ReviewerType.flexible: + self.bottomWidget = FlexibleBottomBar(self) + self.mainLayout.addWidget(self.bottomWidget) + # force webengine processes to load before cwd is changed if is_win: for webview in self.web, self.bottomWeb: @@ -1065,19 +1072,28 @@ title="{}" {}>{}""".format( return self._mainThread == QThread.currentThread() def setupDeckBrowser(self) -> None: - from aqt.deckbrowser import DeckBrowser + from aqt.deckbrowser import DeckBrowser, FlexibleDeckBrowser - self.deckBrowser = DeckBrowser(self) + self.deckBrowser = { + ReviewerType.default: DeckBrowser, + ReviewerType.flexible: FlexibleDeckBrowser, + }[self.pm.reviewer()](self) def setupOverview(self) -> None: - from aqt.overview import Overview + from aqt.overview import FlexibleOverview, Overview - self.overview = Overview(self) + self.overview = { + ReviewerType.default: Overview, + ReviewerType.flexible: FlexibleOverview, + }[self.pm.reviewer()](self) def setupReviewer(self) -> None: - from aqt.reviewer import Reviewer + from aqt.reviewer import FlexibleReviewer, Reviewer - self.reviewer = Reviewer(self) + self.reviewer = { + ReviewerType.default: Reviewer, + ReviewerType.flexible: FlexibleReviewer, + }[self.pm.reviewer()](self) # Syncing ########################################################################## @@ -1167,6 +1183,9 @@ title="{}" {}>{}""".format( self.pm.set_theme(theme) self.setupStyle() + def set_reviewer(self, reviewer: ReviewerType) -> None: + self.pm.set_reviewer(reviewer) + # Key handling ########################################################################## diff --git a/qt/aqt/overview.py b/qt/aqt/overview.py index b1fc9a119..b88817768 100644 --- a/qt/aqt/overview.py +++ b/qt/aqt/overview.py @@ -2,6 +2,7 @@ # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations +import functools import html from collections.abc import Callable from dataclasses import dataclass @@ -14,6 +15,7 @@ from anki.scheduler import UnburyDeck from aqt import gui_hooks from aqt.deckdescription import DeckDescriptionDialog from aqt.deckoptions import display_options_for_deck +from aqt.flexible_grading_reviewer.widgets import FlexiblePushButton from aqt.operations import QueryOp from aqt.operations.scheduling import ( empty_filtered_deck, @@ -280,7 +282,10 @@ class Overview: # Bottom area ###################################################################### - def _renderBottom(self) -> None: + def _make_bottom_links(self) -> list[list[str]]: + """ + Create a list of lists, each holding [shortcut, pycmd, button text] + """ links = [ ["O", "opts", tr.actions_options()], ] @@ -295,6 +300,10 @@ class Overview: links.append(["U", "unbury", tr.studying_unbury()]) if not is_dyn: links.append(["", "description", tr.scheduling_description()]) + return links + + def _renderBottom(self) -> None: + links = self._make_bottom_links() link_handler = gui_hooks.overview_will_render_bottom( self._linkHandler, links, @@ -320,3 +329,48 @@ class Overview: import aqt.customstudy aqt.customstudy.CustomStudy.fetch_data_and_show(self.mw) + + +class FlexibleOverview(Overview): + """ + Adds *Flexible Grading* features to Anki as a separate Reviewer. + The idea is that Anki can have many Reviewer classes, and the user can choose which they prefer. + + Initially, Flexible Grading was implemented as an add-on. + However, add-ons require patching every time Anki introduces a change that breaks add-on compatibility. + Thus, it proves better to add new features directly to Anki. + """ + + def add_bottom_buttons(self) -> None: + self.mw.bottomWidget.left_bucket.reset(is_visible=False) + self.mw.bottomWidget.right_bucket.reset(is_visible=False) + self.mw.bottomWidget.middle_bucket.reset(is_visible=True) + + links = self._make_bottom_links() + pycmds = { + "opts": lambda: display_options_for_deck(self.mw.col.decks.current()), + "refresh": lambda: self.rebuild_current_filtered_deck(), + "empty": lambda: self.empty_current_filtered_deck(), + "studymore": lambda: self.onStudyMore(), + "unbury": lambda: self.on_unbury(), + "description": lambda: self.edit_description(), + } + for keyboard_shortcut, pycmd, button_text in links: + if len(keyboard_shortcut) == 1: + # if shortcut is one letter + button_text += f"[{keyboard_shortcut}]" + button = self.mw.bottomWidget.middle_bucket.add_button( + FlexiblePushButton(text=button_text), + on_clicked=functools.partial(pycmds[pycmd]), + ) + if keyboard_shortcut: + button.setToolTip( + tr.actions_shortcut_key(val=shortcut(keyboard_shortcut)) + ) + + def _clear_bottom_web(self) -> None: + self.bottom.web.setHtml("") + + def _renderBottom(self) -> None: + self._clear_bottom_web() + self.add_bottom_buttons() diff --git a/qt/aqt/preferences.py b/qt/aqt/preferences.py index 939dd8c2c..dea3c0917 100644 --- a/qt/aqt/preferences.py +++ b/qt/aqt/preferences.py @@ -16,7 +16,7 @@ from anki.utils import is_mac from aqt import AnkiQt from aqt.ankihub import ankihub_login, ankihub_logout from aqt.operations.collection import set_preferences -from aqt.profiles import VideoDriver +from aqt.profiles import ReviewerType, VideoDriver from aqt.qt import * from aqt.sync import sync_login from aqt.theme import Theme @@ -372,6 +372,32 @@ class Preferences(QDialog): self.form.styleComboBox.setVisible(not is_win) qconnect(self.form.resetWindowSizes.clicked, self.on_reset_window_sizes) + # Reviewers: Default and Flexible + # Note: these names are set in ftl/core/preferences.ftl + reviewers = [ + f"{tr.preferences_reviewer_type()}: {tr.preferences_reviewer_type_default()}", + f"{tr.preferences_reviewer_type()}: {tr.preferences_reviewer_type_flexible()}", + ] + self.form.reviewerTypeComboBox.addItems(reviewers) + self.form.reviewerTypeComboBox.setCurrentIndex(self.mw.pm.reviewer().value) + qconnect( + self.form.reviewerTypeComboBox.currentIndexChanged, self.on_reviewer_changed + ) + + # Show reps done today + self.form.reviewerShowRepsDoneToday.setChecked( + self.mw.pm.reviewer_show_reps_done_today() + ) + qconnect( + self.form.reviewerShowRepsDoneToday.stateChanged, + self.mw.pm.set_reviewer_show_reps_done_today, + ) + self.form.reviewerShowRepsDoneToday.setVisible( + self.mw.pm.reviewer() == ReviewerType.flexible + ) + + ############## + self.setup_language() self.setup_video_driver() @@ -395,6 +421,12 @@ class Preferences(QDialog): def on_theme_changed(self, index: int) -> None: self.mw.set_theme(Theme(index)) + def on_reviewer_changed(self, index: int) -> None: + self.mw.set_reviewer(ReviewerType(index)) + self.form.reviewerShowRepsDoneToday.setVisible( + self.mw.pm.reviewer() == ReviewerType.flexible + ) + def on_reset_window_sizes(self) -> None: assert self.prof is not None regexp = re.compile(r"(Geom(etry)?|State|Splitter|Header)(\d+.\d+)?$") diff --git a/qt/aqt/profiles.py b/qt/aqt/profiles.py index 919be170c..949eabb88 100644 --- a/qt/aqt/profiles.py +++ b/qt/aqt/profiles.py @@ -3,6 +3,7 @@ from __future__ import annotations +import enum import io import os import pickle @@ -124,6 +125,11 @@ class LoadMetaResult: loadError: bool +class ReviewerType(enum.IntEnum): + default = 0 + flexible = 1 + + class ProfileManager: default_answer_keys = {ease_num: str(ease_num) for ease_num in range(1, 5)} last_run_version: int = 0 @@ -606,6 +612,18 @@ create table if not exists profiles def set_theme(self, theme: Theme) -> None: self.meta["theme"] = theme.value + def reviewer(self) -> ReviewerType: + return ReviewerType(self.meta.get("reviewer_type", 0)) + + def set_reviewer(self, reviewer: ReviewerType) -> None: + self.meta["reviewer_type"] = reviewer.value + + def reviewer_show_reps_done_today(self) -> bool: + return bool(self.meta.get("reviewer_show_reps_done_today", True)) + + def set_reviewer_show_reps_done_today(self, enabled: bool) -> None: + self.meta["reviewer_show_reps_done_today"] = bool(enabled) + def set_widget_style(self, style: WidgetStyle) -> None: self.meta["widget_style"] = style theme_manager.apply_style() diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index 6d68f9e3a..51fc1e6d3 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -6,11 +6,11 @@ from __future__ import annotations import json import random import re -from collections.abc import Callable, Generator, Sequence +from collections.abc import Generator, Sequence from dataclasses import dataclass from enum import Enum, auto from functools import partial -from typing import Any, Literal, Match, Union, cast +from typing import Any, Literal, Match, Optional, cast import aqt import aqt.browser @@ -29,10 +29,16 @@ from anki.scheduler.v3 import ( from anki.scheduler.v3 import Scheduler as V3Scheduler from anki.tags import MARKED_TAG from anki.types import assert_exhaustive -from anki.utils import is_mac +from anki.utils import html_to_text_line from aqt import AnkiQt, gui_hooks from aqt.browser.card_info import PreviousReviewerCardInfo, ReviewerCardInfo from aqt.deckoptions import confirm_deck_then_display_options +from aqt.flexible_grading_reviewer.utils import ( + ease_to_answer_key, + ease_to_answer_key_short, + studied_today_count, +) +from aqt.flexible_grading_reviewer.widgets import FlexiblePushButton, FlexibleTimerLabel from aqt.operations.card import set_card_flag from aqt.operations.note import remove_notes from aqt.operations.scheduling import ( @@ -920,11 +926,7 @@ timerStopped = false; else: extra = "" due = self._buttonTime(i, v3_labels=labels) - key = ( - tr.actions_shortcut_key(val=aqt.mw.pm.get_answer_key(i)) - if aqt.mw.pm.get_answer_key(i) - else "" - ) + key = ease_to_answer_key(ease) return """ """ % ( @@ -1233,6 +1235,176 @@ timerStopped = false; setFlag = set_flag_on_current_card +class FlexibleReviewer(Reviewer): + """ + Adds *Flexible Grading* features to Anki as a separate Reviewer. + The idea is that Anki can have many Reviewer classes, and the user can choose which they prefer. + + Initially, Flexible Grading was implemented as an add-on. + However, add-ons require patching every time Anki introduces a change that breaks add-on compatibility. + Thus, it proves better to add new features directly to Anki. + """ + + _ease_to_color = { + 1: "FireBrick", + 2: "DarkGoldenRod", + 3: "ForestGreen", + 4: "DodgerBlue", + } + _queue_to_color = { + QueuedCards.NEW: "DodgerBlue", + QueuedCards.LEARNING: "FireBrick", + QueuedCards.REVIEW: "ForestGreen", + } + + timer: Optional[FlexibleTimerLabel] = None + + def __init__(self, mw: AnkiQt) -> None: + super().__init__(mw) + self.timer = None + + def cleanup(self) -> None: + super().cleanup() + self.mw.bottomWidget.middle_bucket.reset(is_visible=False) + self.mw.bottomWidget.left_bucket.reset(is_visible=False) + self.mw.bottomWidget.right_bucket.reset(is_visible=False) + + def _bottomHTML(self) -> str: + return "" + + def _create_side_buttons(self) -> None: + # Left side + self.mw.bottomWidget.left_bucket.reset(is_visible=True) + self.mw.bottomWidget.left_bucket.add_button( + FlexiblePushButton(text=tr.studying_edit()), + on_clicked=partial(self.mw.onEditCurrent), + ) + # Right side + self.mw.bottomWidget.right_bucket.reset(is_visible=True) + self.mw.bottomWidget.right_bucket.add_button( + FlexiblePushButton(text=tr.studying_more()), + on_clicked=partial(self.showContextMenu), + ) + + def browse_queue(self, queue_type: Union[str, Any]) -> None: + if queue_type == QueuedCards.LEARNING: + queue_type = "learn" + elif queue_type == QueuedCards.NEW: + queue_type = "new" + else: + queue_type = "due" + self.browse_query(f"is:{queue_type}") + + def browse_query(self, query: str) -> None: + browser: aqt.browser.Browser = aqt.dialogs.open("Browser", self.mw) + browser.activateWindow() + browser.form.searchEdit.lineEdit().setText(query) # search_for + if hasattr(browser, "onSearch"): + browser.onSearch() + else: + browser.onSearchActivated() + + def _answer_button_label(self, ease: int, label: str) -> str: + """ + If estTimes (showEstimates) are enabled, return the estimate as string. + Otherwise, return the first letter of the text label. + """ + if self.mw.col.conf["estTimes"]: + assert isinstance(self.mw.col.sched, V3Scheduler) + button_times = self.mw.col.sched.describe_next_states(self._v3.states) + return button_times[ease - 1] + else: + return html_to_text_line(label)[:1].upper() + + def _create_middle_buttons_for_answer_side(self) -> None: + self.mw.bottomWidget.middle_bucket.reset(is_visible=True) + for ease, label in self._answerButtonList(): + self.mw.bottomWidget.middle_bucket.add_button( + FlexiblePushButton( + text=f"{self._answer_button_label(ease, label)}[{ease_to_answer_key_short(ease)}]", + text_color=self._ease_to_color[ease], + ), + on_clicked=partial(self._answerCard, cast(Literal[1, 2, 3, 4], ease)), + ) + + def _create_middle_buttons_for_question_side(self) -> None: + """ + Show the number of remaining cards in three queues: New, Learning, Review. + """ + self.mw.bottomWidget.middle_bucket.reset(is_visible=True) + + if self.mw.col.conf["dueCounts"]: + counts = { + QueuedCards.NEW: self._v3.queued_cards.new_count, + QueuedCards.LEARNING: self._v3.queued_cards.learning_count, + QueuedCards.REVIEW: self._v3.queued_cards.review_count, + } + this_card = self._v3.top_card() + for queue_type, count in counts.items(): + self.mw.bottomWidget.middle_bucket.add_button( + FlexiblePushButton( + text=f"{count}", + text_color=self._queue_to_color[queue_type], + text_underline=(this_card.queue == queue_type), + ), + on_clicked=partial(self.browse_queue, queue_type), + ) + + # show reps done today + if self.mw.pm.reviewer_show_reps_done_today(): + self.mw.bottomWidget.middle_bucket.add_button( + FlexiblePushButton(text=f"Reps: {studied_today_count(self.mw.col)}"), + on_clicked=partial(self.browse_query, "rated:1"), + ) + + def _clear_bottom_web(self) -> None: + self.bottom.web.setHtml("") + + def _max_time(self) -> int: + if self.card.should_show_timer(): + return self.card.time_limit() // 1000 + else: + return 0 + + def _showAnswerButton(self) -> None: + """ + Show Front side (Question side). Button: Flip card + """ + self._create_side_buttons() + self._create_middle_buttons_for_question_side() + self._clear_bottom_web() + + # Right side: add timer + if (max_time := self._max_time()) > 0: + self.timer = self.mw.bottomWidget.right_bucket.add_widget( + widget=FlexibleTimerLabel() + ) # type: ignore + self.timer.start(max_time=max_time) + + def _should_stop_timer_on_answer(self) -> bool: + conf = self.mw.col.decks.config_dict_for_deck_id(self.card.current_deck_id()) + return bool(conf["stopTimerOnAnswer"]) + + def _showEaseButtons(self) -> None: + """ + Show Back side (Answer side). Buttons: Again, Hard, Good, Easy + """ + if not self._states_mutated: + self.mw.progress.single_shot(50, self._showEaseButtons) + return + self._create_middle_buttons_for_answer_side() + self._clear_bottom_web() + + if self.timer and self._should_stop_timer_on_answer(): + self.timer.stop() + + def onEnterKey(self) -> None: + if self.state == "question": + self._getTypedAnswer() + elif self.state == "answer" and aqt.mw.pm.spacebar_rates_card(): + self._answerCard(self._defaultEase()) + + # if the last element is a comment, then the RUN_STATE_MUTATION code # breaks due to the comment wrongly commenting out python code. # To prevent this we put the js code on a separate line