mirror of
https://github.com/ankitects/anki.git
synced 2026-01-05 18:13:56 -05:00
Merge 971e1604a0 into 8f2144534b
This commit is contained in:
commit
e7954497dd
11 changed files with 610 additions and 16 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
2
qt/aqt/flexible_grading_reviewer/__init__.py
Normal file
2
qt/aqt/flexible_grading_reviewer/__init__.py
Normal 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
|
||||
45
qt/aqt/flexible_grading_reviewer/utils.py
Normal file
45
qt/aqt/flexible_grading_reviewer/utils.py
Normal 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())
|
||||
186
qt/aqt/flexible_grading_reviewer/widgets.py
Normal file
186
qt/aqt/flexible_grading_reviewer/widgets.py
Normal 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)
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
##########################################################################
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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+)?$")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue