This commit is contained in:
Ren Tatsumoto 2025-12-23 11:56:56 +01:00 committed by GitHub
commit e7954497dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 610 additions and 16 deletions

View file

@ -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)

View file

@ -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("<style>body {margin:0;} html {height:0;}</style>")
def _drawButtons(self) -> None:
self._clear_bottom_web()
self.add_bottom_buttons()

View file

@ -0,0 +1,2 @@
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html

View file

@ -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())

View file

@ -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"<font color='red'>{time_string}</font>")
self.stop()
else:
self.setText(time_string)

View file

@ -373,6 +373,26 @@
<string>preferences_review</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_5">
<item>
<widget class="QComboBox" name="reviewerTypeComboBox">
<property name="currentText">
<string/>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="reviewerShowRepsDoneToday">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>preferences_reviewer_show_reps_done_today</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="showPlayButtons">
<property name="sizePolicy">
@ -1267,6 +1287,7 @@
<tabstop>dayOffset</tabstop>
<tabstop>lrnCutoff</tabstop>
<tabstop>timeLimit</tabstop>
<tabstop>reviewerTypeComboBox</tabstop>
<tabstop>showPlayButtons</tabstop>
<tabstop>interrupt_audio</tabstop>
<tabstop>showProgress</tabstop>

View file

@ -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="{}" {}>{}</button>""".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="{}" {}>{}</button>""".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="{}" {}>{}</button>""".format(
self.pm.set_theme(theme)
self.setupStyle()
def set_reviewer(self, reviewer: ReviewerType) -> None:
self.pm.set_reviewer(reviewer)
# Key handling
##########################################################################

View file

@ -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("<style>body {margin:0;} html {height:0;}</style>")
def _renderBottom(self) -> None:
self._clear_bottom_web()
self.add_bottom_buttons()

View file

@ -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+)?$")

View file

@ -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()

View file

@ -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 """
<td align=center><button %s title="%s" data-ease="%s" onclick='pycmd("ease%d");'>\
%s%s</button></td>""" % (
@ -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 "<style></style>"
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("<style>body {margin:0;} html {height:0;}</style>")
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