From 457efc0d62233c8787aa1b029e7de87276fb24fc Mon Sep 17 00:00:00 2001 From: Ren Tatsumoto Date: Sun, 23 Nov 2025 12:57:04 +0300 Subject: [PATCH 01/16] add selector to preferences implement bottom bar add flexible reviewer show reps done today add flexible deck browser add flexible overview --- ftl/core/preferences.ftl | 3 + qt/aqt/deckbrowser.py | 44 ++++++ qt/aqt/flexible_grading_reviewer/__init__.py | 2 + qt/aqt/flexible_grading_reviewer/utils.py | 45 ++++++ qt/aqt/flexible_grading_reviewer/widgets.py | 141 ++++++++++++++++++ qt/aqt/forms/preferences.ui | 7 + qt/aqt/main.py | 31 +++- qt/aqt/overview.py | 57 ++++++- qt/aqt/preferences.py | 17 ++- qt/aqt/profiles.py | 12 ++ qt/aqt/reviewer.py | 149 ++++++++++++++++++- 11 files changed, 492 insertions(+), 16 deletions(-) create mode 100644 qt/aqt/flexible_grading_reviewer/__init__.py create mode 100644 qt/aqt/flexible_grading_reviewer/utils.py create mode 100644 qt/aqt/flexible_grading_reviewer/widgets.py diff --git a/ftl/core/preferences.ftl b/ftl/core/preferences.ftl index 23b72f267..75b820ed1 100644 --- a/ftl/core/preferences.ftl +++ b/ftl/core/preferences.ftl @@ -39,6 +39,9 @@ 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-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 5dc688155..5e8c34509 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,10 @@ 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 ( + FlexibleButtonsList, + FlexiblePushButton, +) from aqt.operations import QueryOp from aqt.operations.deck import ( add_deck_dialog, @@ -436,3 +441,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..6be537d32 --- /dev/null +++ b/qt/aqt/flexible_grading_reviewer/widgets.py @@ -0,0 +1,141 @@ +# 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) -> None: + self._layout.addWidget(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) diff --git a/qt/aqt/forms/preferences.ui b/qt/aqt/forms/preferences.ui index 0035e1f42..6996a8919 100644 --- a/qt/aqt/forms/preferences.ui +++ b/qt/aqt/forms/preferences.ui @@ -451,6 +451,13 @@ + + + + + + + diff --git a/qt/aqt/main.py b/qt/aqt/main.py index c707d1b2a..9054eabef 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 Overview, FlexibleOverview - 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..1cf1d34ae 100644 --- a/qt/aqt/overview.py +++ b/qt/aqt/overview.py @@ -2,7 +2,10 @@ # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations +import functools + import html +from aqt.flexible_grading_reviewer.widgets import FlexiblePushButton from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -280,7 +283,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 +301,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 +330,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..7f8257ae3 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,18 @@ 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 + ) + self.setup_language() self.setup_video_driver() @@ -395,6 +407,9 @@ 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)) + 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..836f0ef2c 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,12 @@ 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 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..5f1c6fdb5 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, cast import aqt import aqt.browser @@ -29,10 +29,19 @@ 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 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 ( + FlexibleButtonsList, + FlexibleHorizontalBar, + FlexiblePushButton, +) from aqt.operations.card import set_card_flag from aqt.operations.note import remove_notes from aqt.operations.scheduling import ( @@ -920,11 +929,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 +1238,134 @@ 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", + } + + def __init__(self, mw: AnkiQt) -> None: + super().__init__(mw) + + 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 _add_side_buttons(self) -> None: + # Left side + self.mw.bottomWidget.left_bucket.reset(is_visible=True) + self.mw.bottomWidget.left_bucket.add_button( + FlexiblePushButton(text="[E]dit"), + 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="[M]ore"), + on_clicked=partial(self.showContextMenu), + ) + + def browse_queue(self, queue_type: Union[str, QueuedCards]) -> 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 _add_middle_buttons_for_question_side(self) -> None: + self.mw.bottomWidget.middle_bucket.reset(is_visible=True) + assert isinstance(self.mw.col.sched, V3Scheduler) + labels = self.mw.col.sched.describe_next_states(self._v3.states) + for ease, label in self._answerButtonList(): + self.mw.bottomWidget.middle_bucket.add_button( + FlexiblePushButton( + text=f"{labels[ease - 1]}[{ease_to_answer_key_short(ease)}]", + text_color=self._ease_to_color[ease], + ), + on_clicked=partial(self._answerCard, ease), + ) + + def _add_middle_buttons_for_answer_side(self) -> None: + 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 + 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 _showAnswerButton(self) -> None: + self._add_side_buttons() + self._add_middle_buttons_for_answer_side() + self._clear_bottom_web() + + def _showEaseButtons(self) -> None: + if not self._states_mutated: + self.mw.progress.single_shot(50, self._showEaseButtons) + return + self._add_side_buttons() + self._add_middle_buttons_for_question_side() + self._clear_bottom_web() + + 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 From e2dbc1a0ab207810f30f326035549ef6f25f71ca Mon Sep 17 00:00:00 2001 From: Ren Tatsumoto Date: Mon, 24 Nov 2025 11:06:00 +0300 Subject: [PATCH 02/16] format --- qt/aqt/main.py | 2 +- qt/aqt/overview.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/qt/aqt/main.py b/qt/aqt/main.py index 9054eabef..762d46453 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -1080,7 +1080,7 @@ title="{}" {}>{}""".format( }[self.pm.reviewer()](self) def setupOverview(self) -> None: - from aqt.overview import Overview, FlexibleOverview + from aqt.overview import FlexibleOverview, Overview self.overview = { ReviewerType.default: Overview, diff --git a/qt/aqt/overview.py b/qt/aqt/overview.py index 1cf1d34ae..b88817768 100644 --- a/qt/aqt/overview.py +++ b/qt/aqt/overview.py @@ -3,9 +3,7 @@ from __future__ import annotations import functools - import html -from aqt.flexible_grading_reviewer.widgets import FlexiblePushButton from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -17,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, From 7a504250c04ff43091a86e5db9355ef455e3b522 Mon Sep 17 00:00:00 2001 From: Ren Tatsumoto Date: Mon, 24 Nov 2025 11:17:17 +0300 Subject: [PATCH 03/16] remove unused --- qt/aqt/deckbrowser.py | 5 +---- qt/aqt/reviewer.py | 6 +----- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/qt/aqt/deckbrowser.py b/qt/aqt/deckbrowser.py index 5e8c34509..0c7206920 100644 --- a/qt/aqt/deckbrowser.py +++ b/qt/aqt/deckbrowser.py @@ -15,10 +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 ( - FlexibleButtonsList, - FlexiblePushButton, -) +from aqt.flexible_grading_reviewer.widgets import FlexiblePushButton from aqt.operations import QueryOp from aqt.operations.deck import ( add_deck_dialog, diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index 5f1c6fdb5..0338d55f6 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -37,11 +37,7 @@ from aqt.flexible_grading_reviewer.utils import ( ease_to_answer_key_short, studied_today_count, ) -from aqt.flexible_grading_reviewer.widgets import ( - FlexibleButtonsList, - FlexibleHorizontalBar, - FlexiblePushButton, -) +from aqt.flexible_grading_reviewer.widgets import FlexiblePushButton from aqt.operations.card import set_card_flag from aqt.operations.note import remove_notes from aqt.operations.scheduling import ( From ddc9958b6b57fe6dacdf40551117231f1fc02f3d Mon Sep 17 00:00:00 2001 From: Ren Tatsumoto Date: Mon, 24 Nov 2025 11:39:06 +0300 Subject: [PATCH 04/16] fix --- qt/aqt/reviewer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index 0338d55f6..8062a5c93 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -1282,7 +1282,7 @@ class FlexibleReviewer(Reviewer): on_clicked=partial(self.showContextMenu), ) - def browse_queue(self, queue_type: Union[str, QueuedCards]) -> None: + def browse_queue(self, queue_type: Union[str, Any]) -> None: if queue_type == QueuedCards.LEARNING: queue_type = "learn" elif queue_type == QueuedCards.NEW: @@ -1310,7 +1310,7 @@ class FlexibleReviewer(Reviewer): text=f"{labels[ease - 1]}[{ease_to_answer_key_short(ease)}]", text_color=self._ease_to_color[ease], ), - on_clicked=partial(self._answerCard, ease), + on_clicked=partial(self._answerCard, cast(Literal[1, 2, 3, 4], ease)), ) def _add_middle_buttons_for_answer_side(self) -> None: From bc7b50ed735a1e8587c118e3aeb660be43d8b03a Mon Sep 17 00:00:00 2001 From: Ren Tatsumoto Date: Tue, 25 Nov 2025 06:48:22 +0300 Subject: [PATCH 05/16] use tr --- qt/aqt/reviewer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index 8062a5c93..edb0b5e30 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -1272,13 +1272,13 @@ class FlexibleReviewer(Reviewer): # Left side self.mw.bottomWidget.left_bucket.reset(is_visible=True) self.mw.bottomWidget.left_bucket.add_button( - FlexiblePushButton(text="[E]dit"), + 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="[M]ore"), + FlexiblePushButton(text=tr.studying_more()), on_clicked=partial(self.showContextMenu), ) From 529084f51b258bed5d5982fae13c9738ee1d3158 Mon Sep 17 00:00:00 2001 From: Ren Tatsumoto Date: Tue, 25 Nov 2025 07:55:56 +0300 Subject: [PATCH 06/16] support setting showEstimates --- qt/aqt/reviewer.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index edb0b5e30..27e2f482c 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -6,6 +6,7 @@ from __future__ import annotations import json import random import re +from anki.utils import html_to_text_line from collections.abc import Generator, Sequence from dataclasses import dataclass from enum import Enum, auto @@ -1300,20 +1301,30 @@ class FlexibleReviewer(Reviewer): else: browser.onSearchActivated() - def _add_middle_buttons_for_question_side(self) -> None: + 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"]: + 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 _add_middle_buttons_for_answer_side(self) -> None: self.mw.bottomWidget.middle_bucket.reset(is_visible=True) assert isinstance(self.mw.col.sched, V3Scheduler) - labels = self.mw.col.sched.describe_next_states(self._v3.states) for ease, label in self._answerButtonList(): self.mw.bottomWidget.middle_bucket.add_button( FlexiblePushButton( - text=f"{labels[ease - 1]}[{ease_to_answer_key_short(ease)}]", + 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 _add_middle_buttons_for_answer_side(self) -> None: + def _add_middle_buttons_for_question_side(self) -> None: self.mw.bottomWidget.middle_bucket.reset(is_visible=True) if self.mw.col.conf["dueCounts"]: From 94a3b38e593cd213fb0b0beb7af69611cb4c4e43 Mon Sep 17 00:00:00 2001 From: Ren Tatsumoto Date: Tue, 25 Nov 2025 07:56:59 +0300 Subject: [PATCH 07/16] move widget --- qt/aqt/forms/preferences.ui | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/qt/aqt/forms/preferences.ui b/qt/aqt/forms/preferences.ui index 6996a8919..a8cdc696b 100644 --- a/qt/aqt/forms/preferences.ui +++ b/qt/aqt/forms/preferences.ui @@ -373,6 +373,13 @@ preferences_review + + + + + + + @@ -451,13 +458,6 @@ - - - - - - - @@ -1274,6 +1274,7 @@ dayOffset lrnCutoff timeLimit + reviewerTypeComboBox showPlayButtons interrupt_audio showProgress From a8e434c5cee4c00d128030e0480fe42262d74abd Mon Sep 17 00:00:00 2001 From: Ren Tatsumoto Date: Tue, 25 Nov 2025 08:23:47 +0300 Subject: [PATCH 08/16] toggle show reps done today --- ftl/core/preferences.ftl | 1 + qt/aqt/forms/preferences.ui | 13 +++++++++++++ qt/aqt/preferences.py | 12 +++++++++--- qt/aqt/profiles.py | 6 ++++++ qt/aqt/reviewer.py | 9 +++++---- 5 files changed, 34 insertions(+), 7 deletions(-) diff --git a/ftl/core/preferences.ftl b/ftl/core/preferences.ftl index 75b820ed1..81e6d0d2e 100644 --- a/ftl/core/preferences.ftl +++ b/ftl/core/preferences.ftl @@ -42,6 +42,7 @@ 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/forms/preferences.ui b/qt/aqt/forms/preferences.ui index a8cdc696b..942e25379 100644 --- a/qt/aqt/forms/preferences.ui +++ b/qt/aqt/forms/preferences.ui @@ -380,6 +380,19 @@ + + + + + 0 + 0 + + + + preferences_reviewer_show_reps_done_today + + + diff --git a/qt/aqt/preferences.py b/qt/aqt/preferences.py index 7f8257ae3..829f3d9d1 100644 --- a/qt/aqt/preferences.py +++ b/qt/aqt/preferences.py @@ -380,9 +380,14 @@ class Preferences(QDialog): ] self.form.reviewerTypeComboBox.addItems(reviewers) self.form.reviewerTypeComboBox.setCurrentIndex(self.mw.pm.reviewer().value) - qconnect( - self.form.reviewerTypeComboBox.currentIndexChanged, self.on_reviewer_changed - ) + 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() @@ -409,6 +414,7 @@ class Preferences(QDialog): 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 diff --git a/qt/aqt/profiles.py b/qt/aqt/profiles.py index 836f0ef2c..949eabb88 100644 --- a/qt/aqt/profiles.py +++ b/qt/aqt/profiles.py @@ -618,6 +618,12 @@ create table if not exists profiles 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 27e2f482c..b3f97a3dd 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -1345,10 +1345,11 @@ class FlexibleReviewer(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"), - ) + 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("") From dc2c16db6ee9af1f998accc2e33dec204d8b05f5 Mon Sep 17 00:00:00 2001 From: Ren Tatsumoto Date: Tue, 25 Nov 2025 08:23:54 +0300 Subject: [PATCH 09/16] fix --- qt/aqt/reviewer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index b3f97a3dd..e42d98fb0 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -1356,7 +1356,7 @@ class FlexibleReviewer(Reviewer): def _showAnswerButton(self) -> None: self._add_side_buttons() - self._add_middle_buttons_for_answer_side() + self._add_middle_buttons_for_question_side() self._clear_bottom_web() def _showEaseButtons(self) -> None: @@ -1364,7 +1364,7 @@ class FlexibleReviewer(Reviewer): self.mw.progress.single_shot(50, self._showEaseButtons) return self._add_side_buttons() - self._add_middle_buttons_for_question_side() + self._add_middle_buttons_for_answer_side() self._clear_bottom_web() def onEnterKey(self) -> None: From 25bae81221c4a95ce7e2217ca7e923f733b9cb9c Mon Sep 17 00:00:00 2001 From: Ren Tatsumoto Date: Tue, 25 Nov 2025 08:32:28 +0300 Subject: [PATCH 10/16] format --- qt/aqt/preferences.py | 21 ++++++++++++++++----- qt/aqt/reviewer.py | 4 ++-- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/qt/aqt/preferences.py b/qt/aqt/preferences.py index 829f3d9d1..dea3c0917 100644 --- a/qt/aqt/preferences.py +++ b/qt/aqt/preferences.py @@ -380,12 +380,21 @@ class Preferences(QDialog): ] self.form.reviewerTypeComboBox.addItems(reviewers) self.form.reviewerTypeComboBox.setCurrentIndex(self.mw.pm.reviewer().value) - qconnect(self.form.reviewerTypeComboBox.currentIndexChanged, self.on_reviewer_changed) + 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.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 + ) ############## @@ -414,7 +423,9 @@ class Preferences(QDialog): def on_reviewer_changed(self, index: int) -> None: self.mw.set_reviewer(ReviewerType(index)) - self.form.reviewerShowRepsDoneToday.setVisible(self.mw.pm.reviewer() == ReviewerType.flexible) + self.form.reviewerShowRepsDoneToday.setVisible( + self.mw.pm.reviewer() == ReviewerType.flexible + ) def on_reset_window_sizes(self) -> None: assert self.prof is not None diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index e42d98fb0..0ef957eed 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -6,7 +6,6 @@ from __future__ import annotations import json import random import re -from anki.utils import html_to_text_line from collections.abc import Generator, Sequence from dataclasses import dataclass from enum import Enum, auto @@ -30,6 +29,7 @@ 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 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 @@ -1301,7 +1301,7 @@ class FlexibleReviewer(Reviewer): else: browser.onSearchActivated() - def _answer_button_label(self, ease: int, label: str) -> str: + 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. From 1eba72a1e2892514f4c918ed1a0395d8fe496957 Mon Sep 17 00:00:00 2001 From: Ren Tatsumoto Date: Tue, 25 Nov 2025 08:36:30 +0300 Subject: [PATCH 11/16] fix --- qt/aqt/reviewer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index 0ef957eed..d6d32a0a6 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -1307,6 +1307,7 @@ class FlexibleReviewer(Reviewer): 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: @@ -1314,7 +1315,6 @@ class FlexibleReviewer(Reviewer): def _add_middle_buttons_for_answer_side(self) -> None: self.mw.bottomWidget.middle_bucket.reset(is_visible=True) - assert isinstance(self.mw.col.sched, V3Scheduler) for ease, label in self._answerButtonList(): self.mw.bottomWidget.middle_bucket.add_button( FlexiblePushButton( From 29691d38d40eba7c83d797d49f045ffbd99ec0ab Mon Sep 17 00:00:00 2001 From: Ren Tatsumoto Date: Sun, 30 Nov 2025 03:32:10 +0300 Subject: [PATCH 12/16] add timer --- qt/aqt/flexible_grading_reviewer/widgets.py | 52 +++++++++++++++++++++ qt/aqt/reviewer.py | 26 ++++++++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/qt/aqt/flexible_grading_reviewer/widgets.py b/qt/aqt/flexible_grading_reviewer/widgets.py index 6be537d32..e1ae933fb 100644 --- a/qt/aqt/flexible_grading_reviewer/widgets.py +++ b/qt/aqt/flexible_grading_reviewer/widgets.py @@ -139,3 +139,55 @@ class FlexibleBottomBar(FlexibleHorizontalBar): self.add_widget(self.middle_bucket) self.add_stretch() self.add_widget(self.right_bucket) + + +class FlexibleTimerLabel(QLabel): + def __init__(self, parent=None): + super().__init__(parent) + self._time = 0 # current time (seconds) + self._max_time = 0 # maximum time (seconds); 0 means hidden + self._qtimer = QTimer(self) + self._qtimer.setInterval(1000) + qconnect(self._qtimer.timeout, self._on_tick) + self.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.setHidden(True) + + def start(self, max_time: int): + self._time = 0 + self._max_time = max_time + self._update_display() + if self._qtimer.isActive(): + self._qtimer.stop() + self._qtimer.start() + + def stop(self): + if self._qtimer.isActive(): + self._qtimer.stop() + + # Internal tick handler + def _on_tick(self): + self._time += 1 + # clamp to max_time if set (mirrors TS: time = Math.min(maxTime, time)) + if self._time > self._max_time > 0: + self._time = self._max_time + self._update_display() + # if reached max, keep ticking but display in red (TS continues interval) + + def _update_display(self): + if self._max_time <= 0: + super().setText("") # hide when max_time == 0 + self.setHidden(True) + return + + self.setHidden(False) + t = min(self._max_time, self._time) if self._max_time > 0 else self._time + m = t // 60 + s = t % 60 + s_str = f"{s:02d}" + time_string = f"{m}:{s_str}" + + if t >= self._max_time > 0: + # display red when time == maxTime (using simple HTML) + super().setText(f"{time_string}") + else: + super().setText(time_string) diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index d6d32a0a6..05c2d4bdf 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -10,7 +10,7 @@ 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, cast +from typing import Any, Literal, Match, Optional, cast import aqt import aqt.browser @@ -38,7 +38,7 @@ from aqt.flexible_grading_reviewer.utils import ( ease_to_answer_key_short, studied_today_count, ) -from aqt.flexible_grading_reviewer.widgets import FlexiblePushButton +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 ( @@ -1257,8 +1257,11 @@ class FlexibleReviewer(Reviewer): 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() @@ -1282,6 +1285,8 @@ class FlexibleReviewer(Reviewer): FlexiblePushButton(text=tr.studying_more()), on_clicked=partial(self.showContextMenu), ) + # Right side: add timer + self.timer = self.mw.bottomWidget.right_bucket.add_widget(FlexibleTimerLabel()) def browse_queue(self, queue_type: Union[str, Any]) -> None: if queue_type == QueuedCards.LEARNING: @@ -1354,11 +1359,24 @@ class FlexibleReviewer(Reviewer): 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: self._add_side_buttons() self._add_middle_buttons_for_question_side() self._clear_bottom_web() + assert self.timer, "timer should exist." + self.timer.start(max_time=self._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: if not self._states_mutated: self.mw.progress.single_shot(50, self._showEaseButtons) @@ -1367,6 +1385,10 @@ class FlexibleReviewer(Reviewer): self._add_middle_buttons_for_answer_side() self._clear_bottom_web() + assert self.timer, "timer should exist." + if self._should_stop_timer_on_answer(): + self.timer.stop() + def onEnterKey(self) -> None: if self.state == "question": self._getTypedAnswer() From e81979576338382d365e1f2d3bfe652b9fd4197c Mon Sep 17 00:00:00 2001 From: Ren Tatsumoto Date: Sun, 30 Nov 2025 03:32:52 +0300 Subject: [PATCH 13/16] rename --- qt/aqt/reviewer.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index 05c2d4bdf..4218e83aa 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -1272,7 +1272,7 @@ class FlexibleReviewer(Reviewer): def _bottomHTML(self) -> str: return "" - def _add_side_buttons(self) -> None: + def _create_side_buttons(self) -> None: # Left side self.mw.bottomWidget.left_bucket.reset(is_visible=True) self.mw.bottomWidget.left_bucket.add_button( @@ -1318,7 +1318,7 @@ class FlexibleReviewer(Reviewer): else: return html_to_text_line(label)[:1].upper() - def _add_middle_buttons_for_answer_side(self) -> None: + 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( @@ -1329,7 +1329,10 @@ class FlexibleReviewer(Reviewer): on_clicked=partial(self._answerCard, cast(Literal[1, 2, 3, 4], ease)), ) - def _add_middle_buttons_for_question_side(self) -> None: + 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"]: @@ -1366,8 +1369,11 @@ class FlexibleReviewer(Reviewer): return 0 def _showAnswerButton(self) -> None: - self._add_side_buttons() - self._add_middle_buttons_for_question_side() + """ + Show Front side (Question side). Button: Flip card + """ + self._create_side_buttons() + self._create_middle_buttons_for_question_side() self._clear_bottom_web() assert self.timer, "timer should exist." @@ -1378,11 +1384,13 @@ class FlexibleReviewer(Reviewer): 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._add_side_buttons() - self._add_middle_buttons_for_answer_side() + self._create_middle_buttons_for_answer_side() self._clear_bottom_web() assert self.timer, "timer should exist." From aefa53984e4419e51ccf6307b9d63e0469ed786c Mon Sep 17 00:00:00 2001 From: Ren Tatsumoto Date: Sun, 30 Nov 2025 03:37:33 +0300 Subject: [PATCH 14/16] add return type --- qt/aqt/flexible_grading_reviewer/widgets.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/qt/aqt/flexible_grading_reviewer/widgets.py b/qt/aqt/flexible_grading_reviewer/widgets.py index e1ae933fb..d1f8aea66 100644 --- a/qt/aqt/flexible_grading_reviewer/widgets.py +++ b/qt/aqt/flexible_grading_reviewer/widgets.py @@ -96,8 +96,9 @@ class FlexibleHorizontalBar(QWidget): def add_stretch(self, stretch_value: int = 1) -> None: return self._layout.addStretch(stretch_value) - def add_widget(self, widget: QWidget) -> None: + 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) @@ -142,7 +143,7 @@ class FlexibleBottomBar(FlexibleHorizontalBar): class FlexibleTimerLabel(QLabel): - def __init__(self, parent=None): + def __init__(self, parent=None) -> None: super().__init__(parent) self._time = 0 # current time (seconds) self._max_time = 0 # maximum time (seconds); 0 means hidden @@ -152,7 +153,7 @@ class FlexibleTimerLabel(QLabel): self.setAlignment(Qt.AlignmentFlag.AlignCenter) self.setHidden(True) - def start(self, max_time: int): + def start(self, max_time: int) -> None: self._time = 0 self._max_time = max_time self._update_display() @@ -160,12 +161,12 @@ class FlexibleTimerLabel(QLabel): self._qtimer.stop() self._qtimer.start() - def stop(self): + def stop(self) -> None: if self._qtimer.isActive(): self._qtimer.stop() # Internal tick handler - def _on_tick(self): + def _on_tick(self) -> None: self._time += 1 # clamp to max_time if set (mirrors TS: time = Math.min(maxTime, time)) if self._time > self._max_time > 0: @@ -173,7 +174,7 @@ class FlexibleTimerLabel(QLabel): self._update_display() # if reached max, keep ticking but display in red (TS continues interval) - def _update_display(self): + def _update_display(self) -> None: if self._max_time <= 0: super().setText("") # hide when max_time == 0 self.setHidden(True) From 0b02be28ed039e443115d15abc1edc62c9061f1b Mon Sep 17 00:00:00 2001 From: Ren Tatsumoto Date: Sun, 30 Nov 2025 03:58:34 +0300 Subject: [PATCH 15/16] refactor --- qt/aqt/flexible_grading_reviewer/widgets.py | 30 ++++++++------------- qt/aqt/reviewer.py | 13 ++++----- 2 files changed, 18 insertions(+), 25 deletions(-) diff --git a/qt/aqt/flexible_grading_reviewer/widgets.py b/qt/aqt/flexible_grading_reviewer/widgets.py index d1f8aea66..ed86c3751 100644 --- a/qt/aqt/flexible_grading_reviewer/widgets.py +++ b/qt/aqt/flexible_grading_reviewer/widgets.py @@ -146,16 +146,17 @@ 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 hidden + 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) - self.setHidden(True) def start(self, max_time: int) -> None: - self._time = 0 + 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() @@ -165,30 +166,21 @@ class FlexibleTimerLabel(QLabel): if self._qtimer.isActive(): self._qtimer.stop() - # Internal tick handler def _on_tick(self) -> None: - self._time += 1 - # clamp to max_time if set (mirrors TS: time = Math.min(maxTime, time)) - if self._time > self._max_time > 0: - self._time = self._max_time + self._time = min(self._time + 1, self._max_time) self._update_display() - # if reached max, keep ticking but display in red (TS continues interval) def _update_display(self) -> None: if self._max_time <= 0: - super().setText("") # hide when max_time == 0 - self.setHidden(True) - return + raise ValueError("max time should be greater than 0") - self.setHidden(False) - t = min(self._max_time, self._time) if self._max_time > 0 else self._time - m = t // 60 - s = t % 60 + 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: - # display red when time == maxTime (using simple HTML) - super().setText(f"{time_string}") + self.setText(f"{time_string}") + self.stop() else: - super().setText(time_string) + self.setText(time_string) diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index 4218e83aa..c1be0f010 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -1285,8 +1285,6 @@ class FlexibleReviewer(Reviewer): FlexiblePushButton(text=tr.studying_more()), on_clicked=partial(self.showContextMenu), ) - # Right side: add timer - self.timer = self.mw.bottomWidget.right_bucket.add_widget(FlexibleTimerLabel()) def browse_queue(self, queue_type: Union[str, Any]) -> None: if queue_type == QueuedCards.LEARNING: @@ -1376,8 +1374,12 @@ class FlexibleReviewer(Reviewer): self._create_middle_buttons_for_question_side() self._clear_bottom_web() - assert self.timer, "timer should exist." - self.timer.start(max_time=self._max_time()) + # Right side: add timer + if (max_time := self._max_time()) > 0: + self.timer = self.mw.bottomWidget.right_bucket.add_widget( + widget=FlexibleTimerLabel() + ) + 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()) @@ -1393,8 +1395,7 @@ class FlexibleReviewer(Reviewer): self._create_middle_buttons_for_answer_side() self._clear_bottom_web() - assert self.timer, "timer should exist." - if self._should_stop_timer_on_answer(): + if self.timer and self._should_stop_timer_on_answer(): self.timer.stop() def onEnterKey(self) -> None: From 971e1604a03bb91ad87c1138a46dff20098dec5c Mon Sep 17 00:00:00 2001 From: Ren Tatsumoto Date: Sun, 30 Nov 2025 11:59:38 +0300 Subject: [PATCH 16/16] fix --- qt/aqt/reviewer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index c1be0f010..51fc1e6d3 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -1378,7 +1378,7 @@ class FlexibleReviewer(Reviewer): 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: