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