Add auto-advance options to deck preset (#2765)

* Move stop-timer-on-answer strings to correct section

* Add auto-advance options to deck preset

* Implement answer actions

* Fix error when last card is answered before timeout

* Fix deserialization of answerAction

* Add answerAction to reserved key list

* Fix inverted boolean

* Add option to wait for audio to finish

* Add auto-advance toggle

* Add shortcut

* Disable auto-advance when main window state changes

* Start auto-advance timer after option is toggled

* Disable auto-advance when main window loses focus

* Use existing translations

* Add Answer Hard and Show Reminder
This commit is contained in:
Abdo 2023-11-13 03:41:51 +03:00 committed by GitHub
parent 46890fbbaa
commit ae7b14bf40
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 311 additions and 7 deletions

View file

@ -222,6 +222,17 @@ deck-config-maximum-answer-secs-tooltip =
deck-config-show-answer-timer-tooltip =
In the review screen, show a timer that counts the number of seconds you're
taking to review each card.
deck-config-stop-timer-on-answer = Stop timer on answer
deck-config-stop-timer-on-answer-tooltip =
Whether to stop the timer when the answer is revealed.
This doesn't affect statistics.
deck-config-seconds-to-show-question = Seconds to show question
deck-config-seconds-to-show-question-tooltip = The number of seconds to wait before automatically advancing to the next question. Set to 0 to disable.
deck-config-seconds-to-show-answer = Seconds to show answer
deck-config-seconds-to-show-answer-tooltip = The number of seconds to wait before automatically revealing the answer. Set to 0 to disable.
deck-config-answer-action = Answer action
deck-config-answer-action-tooltip = The action to perform on the current card before automatically advancing to the next one.
deck-config-wait-for-audio-tooltip = Wait for audio to finish before automatically revealing answer or next question
## Audio section
@ -234,11 +245,6 @@ deck-config-skip-question-when-replaying = Skip question when replaying answer
deck-config-always-include-question-audio-tooltip =
Whether the question audio should be included when the Replay action is
used while looking at the answer side of a card.
deck-config-stop-timer-on-answer = Stop timer on answer
deck-config-stop-timer-on-answer-tooltip =
Whether to stop the timer when the answer is revealed.
This doesn't affect statistics.
## Advanced section
deck-config-advanced-title = Advanced

View file

@ -56,3 +56,4 @@ studying-minute =
[one] { $count } minute.
*[other] { $count } minutes.
}
studying-answer-time-elapsed = Answer time elapsed

View file

@ -91,6 +91,13 @@ message DeckConfig {
LEECH_ACTION_SUSPEND = 0;
LEECH_ACTION_TAG_ONLY = 1;
}
enum AnswerAction {
ANSWER_ACTION_BURY_CARD = 0;
ANSWER_ACTION_ANSWER_AGAIN = 1;
ANSWER_ACTION_ANSWER_GOOD = 2;
ANSWER_ACTION_ANSWER_HARD = 3;
ANSWER_ACTION_SHOW_REMINDER = 4;
}
repeated float learn_steps = 1;
repeated float relearn_steps = 2;
@ -133,6 +140,10 @@ message DeckConfig {
uint32 cap_answer_time_to_secs = 24;
bool show_timer = 25;
bool stop_timer_on_answer = 38;
float seconds_to_show_question = 41;
float seconds_to_show_answer = 42;
AnswerAction answer_action = 43;
bool wait_for_audio = 44;
bool skip_question_when_replaying_answer = 26;
bool bury_new = 27;

View file

@ -161,6 +161,11 @@ class MainWebView(AnkiWebView):
self.mw.bottomWeb.hide_timer.start()
return True
if evt.type() == QEvent.Type.FocusOut:
self.mw._auto_advance_was_enabled = self.mw.reviewer.auto_advance_enabled
self.mw.reviewer.auto_advance_enabled = False
return True
return False
@ -189,6 +194,7 @@ class AnkiQt(QMainWindow):
self.app = app
self.pm = profileManager
self.fullscreen = False
self._auto_advance_was_enabled = False
# init rest of app
self.safeMode = (
bool(self.app.queryKeyboardModifiers() & Qt.KeyboardModifier.ShiftModifier)
@ -822,6 +828,8 @@ class AnkiQt(QMainWindow):
if new_focus and new_focus.window() == self:
if self.state == "review":
self.reviewer.refresh_if_needed()
self.reviewer.auto_advance_enabled = self._auto_advance_was_enabled
self.reviewer.auto_advance_if_enabled()
elif self.state == "overview":
self.overview.refresh_if_needed()
elif self.state == "deckBrowser":
@ -1021,6 +1029,7 @@ title="{}" {}>{}</button>""".format(
from aqt.reviewer import Reviewer
self.reviewer = Reviewer(self)
self._auto_advance_was_enabled = self.reviewer.auto_advance_enabled
# Syncing
##########################################################################

View file

@ -129,6 +129,14 @@ class V3CardInfo:
return CardAnswer.EASY
class AnswerAction(Enum):
BURY_CARD = 0
ANSWER_AGAIN = 1
ANSWER_GOOD = 2
ANSWER_HARD = 3
SHOW_REMINDER = 4
class Reviewer:
def __init__(self, mw: AnkiQt) -> None:
self.mw = mw
@ -147,6 +155,10 @@ class Reviewer:
self._previous_card_info = PreviousReviewerCardInfo(self.mw)
self._states_mutated = True
self._reps: int = None
self._show_question_timer: QTimer | None = None
self._show_answer_timer: QTimer | None = None
self.auto_advance_enabled = False
gui_hooks.av_player_did_end_playing.append(self._on_av_player_did_end_playing)
def show(self) -> None:
if self.mw.col.sched_ver() == 1 or not self.mw.col.v3_scheduler():
@ -175,6 +187,7 @@ class Reviewer:
def cleanup(self) -> None:
gui_hooks.reviewer_will_end()
self.card = None
self.auto_advance_enabled = False
def refresh_if_needed(self) -> None:
if self._refresh_needed is RefreshNeeded.QUEUES:
@ -282,6 +295,21 @@ class Reviewer:
replay_audio(self.card, False)
gui_hooks.audio_will_replay(self.web, self.card, self.state == "question")
def _on_av_player_did_end_playing(self, *args) -> None:
def task() -> None:
if av_player.queue_is_empty():
if self._show_question_timer and not sip.isdeleted(
self._show_question_timer
):
self._on_show_question_timeout()
elif self._show_answer_timer and not sip.isdeleted(
self._show_answer_timer
):
self._on_show_answer_timeout()
# Allow time for audio queue to update
self.mw.taskman.run_on_main(lambda: self.mw.progress.single_shot(100, task))
# Initializing the webview
##########################################################################
@ -363,6 +391,35 @@ class Reviewer:
self.mw.web.setFocus()
# user hook
gui_hooks.reviewer_did_show_question(c)
self._auto_advance_to_answer_if_enabled()
def _auto_advance_to_answer_if_enabled(self) -> None:
if self.auto_advance_enabled:
conf = self.mw.col.decks.config_dict_for_deck_id(
self.card.current_deck_id()
)
timer = None
if conf["secondsToShowAnswer"]:
timer = self._show_answer_timer = self.mw.progress.timer(
int(conf["secondsToShowAnswer"] * 1000),
lambda: self._on_show_answer_timeout(timer),
repeat=False,
parent=self.mw,
)
def _on_show_answer_timeout(self, timer: QTimer | None = None) -> None:
if self.card is None:
return
conf = self.mw.col.decks.config_dict_for_deck_id(self.card.current_deck_id())
if (conf["waitForAudio"] and av_player.current_player) or (
timer and self._show_answer_timer != timer
):
return
if self._show_answer_timer is not None:
self._show_answer_timer.deleteLater()
if not self.auto_advance_enabled:
return
self._showAnswer()
def autoplay(self, card: Card) -> bool:
print("use card.autoplay() instead of reviewer.autoplay(card)")
@ -404,6 +461,48 @@ class Reviewer:
self.mw.web.setFocus()
# user hook
gui_hooks.reviewer_did_show_answer(c)
self._auto_advance_to_question_if_enabled()
def _auto_advance_to_question_if_enabled(self) -> None:
if self.auto_advance_enabled:
conf = self.mw.col.decks.config_dict_for_deck_id(
self.card.current_deck_id()
)
timer = None
if conf["secondsToShowQuestion"]:
timer = self._show_question_timer = self.mw.progress.timer(
int(conf["secondsToShowQuestion"] * 1000),
lambda: self._on_show_question_timeout(timer),
repeat=False,
parent=self.mw,
)
def _on_show_question_timeout(self, timer: QTimer | None = None) -> None:
if self.card is None:
return
conf = self.mw.col.decks.config_dict_for_deck_id(self.card.current_deck_id())
if (conf["waitForAudio"] and av_player.current_player) or (
timer and self._show_question_timer != timer
):
return
if self._show_question_timer is not None:
self._show_question_timer.deleteLater()
if not self.auto_advance_enabled:
return
try:
answer_action = list(AnswerAction)[conf["answerAction"]]
except IndexError:
answer_action = AnswerAction.ANSWER_GOOD
if answer_action == AnswerAction.BURY_CARD:
self.bury_current_card()
elif answer_action == AnswerAction.ANSWER_AGAIN:
self._answerCard(1)
elif answer_action == AnswerAction.ANSWER_HARD:
self._answerCard(2)
elif answer_action == AnswerAction.SHOW_REMINDER:
tooltip(tr.studying_answer_time_elapsed())
else:
self._answerCard(3)
# Answering a card
############################################################
@ -507,6 +606,7 @@ class Reviewer:
("5", self.on_pause_audio),
("6", self.on_seek_backward),
("7", self.on_seek_forward),
("Shift+A", self.toggle_auto_advance),
*self.korean_shortcuts(),
]
@ -883,6 +983,12 @@ timerStopped = false;
[tr.studying_audio_and5s(), "7", self.on_seek_forward],
[tr.studying_record_own_voice(), "Shift+V", self.onRecordVoice],
[tr.studying_replay_own_voice(), "V", self.onReplayRecorded],
[
tr.actions_auto_advance(),
"Shift+A",
self.toggle_auto_advance,
dict(checked=self.auto_advance_enabled),
],
]
return opts
@ -1039,6 +1145,16 @@ timerStopped = false;
return
av_player.play_file(self._recordedAudio)
def toggle_auto_advance(self) -> None:
self.auto_advance_enabled = not self.auto_advance_enabled
self.auto_advance_if_enabled()
def auto_advance_if_enabled(self) -> None:
if self.state == "question":
self._auto_advance_to_answer_if_enabled()
elif self.state == "answer":
self._auto_advance_to_question_if_enabled()
# legacy
onBuryCard = bury_current_card

View file

@ -155,7 +155,7 @@ class AVPlayer:
self._play_next_if_idle()
def queue_is_empty(self) -> bool:
return bool(self._enqueued)
return not bool(self._enqueued)
def stop_and_clear_queue(self) -> None:
self._enqueued = []

View file

@ -6,6 +6,7 @@ mod service;
pub(crate) mod undo;
mod update;
pub use anki_proto::deck_config::deck_config::config::AnswerAction;
pub use anki_proto::deck_config::deck_config::config::LeechAction;
pub use anki_proto::deck_config::deck_config::config::NewCardGatherPriority;
pub use anki_proto::deck_config::deck_config::config::NewCardInsertOrder;
@ -63,6 +64,10 @@ const DEFAULT_DECK_CONFIG_INNER: DeckConfigInner = DeckConfigInner {
cap_answer_time_to_secs: 60,
show_timer: false,
stop_timer_on_answer: false,
seconds_to_show_question: 0.0,
seconds_to_show_answer: 0.0,
answer_action: AnswerAction::BuryCard as i32,
wait_for_audio: true,
skip_question_when_replaying_answer: false,
bury_new: false,
bury_reviews: false,

View file

@ -24,6 +24,10 @@ use crate::serde::default_on_invalid;
use crate::timestamp::TimestampSecs;
use crate::types::Usn;
fn wait_for_audio_default() -> bool {
true
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
#[serde(rename_all = "camelCase")]
pub struct DeckConfSchema11 {
@ -72,6 +76,14 @@ pub struct DeckConfSchema11 {
#[serde(default)]
stop_timer_on_answer: bool,
#[serde(default)]
seconds_to_show_question: f32,
#[serde(default)]
seconds_to_show_answer: f32,
#[serde(default)]
answer_action: AnswerAction,
#[serde(default = "wait_for_audio_default")]
wait_for_audio: bool,
#[serde(default)]
reschedule_fsrs_cards: bool,
#[serde(default)]
sm2_retention: f32,
@ -80,6 +92,18 @@ pub struct DeckConfSchema11 {
other: HashMap<String, Value>,
}
#[derive(Serialize_repr, Deserialize_repr, Debug, PartialEq, Eq, Clone)]
#[repr(u8)]
#[derive(Default)]
pub enum AnswerAction {
#[default]
BuryCard = 0,
AnswerAgain = 1,
AnswerGood = 2,
AnswerHard = 3,
ShowReminder = 4,
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
#[serde(rename_all = "camelCase")]
pub struct NewConfSchema11 {
@ -249,6 +273,10 @@ impl Default for DeckConfSchema11 {
autoplay: true,
timer: 0,
stop_timer_on_answer: false,
seconds_to_show_question: 0.0,
seconds_to_show_answer: 0.0,
answer_action: AnswerAction::BuryCard,
wait_for_audio: true,
replayq: true,
dynamic: false,
new: Default::default(),
@ -331,6 +359,10 @@ impl From<DeckConfSchema11> for DeckConfig {
cap_answer_time_to_secs: c.max_taken.max(0) as u32,
show_timer: c.timer != 0,
stop_timer_on_answer: c.stop_timer_on_answer,
seconds_to_show_question: c.seconds_to_show_question,
seconds_to_show_answer: c.seconds_to_show_answer,
answer_action: c.answer_action as i32,
wait_for_audio: c.wait_for_audio,
skip_question_when_replaying_answer: !c.replayq,
bury_new: c.new.bury,
bury_reviews: c.rev.bury,
@ -385,6 +417,16 @@ impl From<DeckConfig> for DeckConfSchema11 {
autoplay: !i.disable_autoplay,
timer: i.show_timer.into(),
stop_timer_on_answer: i.stop_timer_on_answer,
seconds_to_show_question: i.seconds_to_show_question,
seconds_to_show_answer: i.seconds_to_show_answer,
answer_action: match i.answer_action {
0 => AnswerAction::BuryCard,
1 => AnswerAction::AnswerAgain,
3 => AnswerAction::AnswerHard,
4 => AnswerAction::ShowReminder,
_ => AnswerAction::AnswerGood,
},
wait_for_audio: i.wait_for_audio,
replayq: !i.skip_question_when_replaying_answer,
dynamic: false,
new: NewConfSchema11 {
@ -459,6 +501,10 @@ static RESERVED_DECKCONF_KEYS: Set<&'static str> = phf_set! {
"fsrsWeights",
"desiredRetention",
"stopTimerOnAnswer",
"secondsToShowQuestion",
"secondsToShowAnswer",
"answerAction",
"waitForAudio",
"rescheduleFsrsCards",
"sm2Retention",
};

View file

@ -13,6 +13,7 @@
export let defaultValue: number;
export let min = 0;
export let max = 9999;
export let step = 0.01;
</script>
<Row --cols={13}>
@ -21,7 +22,7 @@
</Col>
<Col --col-size={6} breakpoint="xs">
<ConfigInput>
<SpinBox bind:value {min} {max} step={0.01} />
<SpinBox bind:value {min} {max} {step} />
<RevertButton slot="revert" bind:value {defaultValue} />
</ConfigInput>
</Col>

View file

@ -9,13 +9,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type Modal from "bootstrap/js/dist/modal";
import DynamicallySlottable from "../components/DynamicallySlottable.svelte";
import EnumSelectorRow from "../components/EnumSelectorRow.svelte";
import HelpModal from "../components/HelpModal.svelte";
import Item from "../components/Item.svelte";
import SettingTitle from "../components/SettingTitle.svelte";
import SwitchRow from "../components/SwitchRow.svelte";
import TitledContainer from "../components/TitledContainer.svelte";
import type { HelpItem } from "../components/types";
import { answerChoices } from "./choices";
import type { DeckOptionsState } from "./lib";
import SpinBoxFloatRow from "./SpinBoxFloatRow.svelte";
import SpinBoxRow from "./SpinBoxRow.svelte";
import Warning from "./Warning.svelte";
@ -43,6 +46,22 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
title: tr.deckConfigStopTimerOnAnswer(),
help: tr.deckConfigStopTimerOnAnswerTooltip(),
},
secondsToShowQuestion: {
title: tr.deckConfigSecondsToShowQuestion(),
help: tr.deckConfigSecondsToShowQuestionTooltip(),
},
secondsToShowAnswer: {
title: tr.deckConfigSecondsToShowAnswer(),
help: tr.deckConfigSecondsToShowAnswerTooltip(),
},
waitForAudio: {
title: tr.deckConfigWaitForAudio(),
help: tr.deckConfigWaitForAudioTooltip(),
},
answerAction: {
title: tr.deckConfigAnswerAction(),
help: tr.deckConfigAnswerActionTooltip(),
},
};
const helpSections = Object.values(settings) as HelpItem[];
@ -125,5 +144,68 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</SwitchRow>
</div>
</Item>
<Item>
<SpinBoxFloatRow
bind:value={$config.secondsToShowQuestion}
defaultValue={defaults.secondsToShowQuestion}
step={1}
>
<SettingTitle
on:click={() =>
openHelpModal(
Object.keys(settings).indexOf("secondsToShowQuestion"),
)}
>
{settings.secondsToShowQuestion.title}
</SettingTitle>
</SpinBoxFloatRow>
</Item>
<Item>
<SpinBoxFloatRow
bind:value={$config.secondsToShowAnswer}
defaultValue={defaults.secondsToShowAnswer}
step={1}
>
<SettingTitle
on:click={() =>
openHelpModal(
Object.keys(settings).indexOf("secondsToShowAnswer"),
)}
>
{settings.secondsToShowAnswer.title}
</SettingTitle>
</SpinBoxFloatRow>
</Item>
<Item>
<SwitchRow
bind:value={$config.waitForAudio}
defaultValue={defaults.waitForAudio}
>
<SettingTitle
on:click={() =>
openHelpModal(Object.keys(settings).indexOf("waitForAudio"))}
>
{settings.waitForAudio.title}
</SettingTitle>
</SwitchRow>
</Item>
<Item>
<EnumSelectorRow
bind:value={$config.answerAction}
defaultValue={defaults.answerAction}
choices={answerChoices()}
>
<SettingTitle
on:click={() =>
openHelpModal(Object.keys(settings).indexOf("answerAction"))}
>
{settings.answerAction.title}
</SettingTitle>
</EnumSelectorRow>
</Item>
</DynamicallySlottable>
</TitledContainer>

View file

@ -2,6 +2,7 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import {
DeckConfig_Config_AnswerAction,
DeckConfig_Config_LeechAction,
DeckConfig_Config_NewCardGatherPriority,
DeckConfig_Config_NewCardInsertOrder,
@ -149,3 +150,28 @@ export function newInsertOrderChoices(): Choice<DeckConfig_Config_NewCardInsertO
},
];
}
export function answerChoices(): Choice<DeckConfig_Config_AnswerAction>[] {
return [
{
label: tr.studyingBuryCard(),
value: DeckConfig_Config_AnswerAction.BURY_CARD,
},
{
label: tr.deckConfigAnswerAgain(),
value: DeckConfig_Config_AnswerAction.ANSWER_AGAIN,
},
{
label: tr.deckConfigAnswerGood(),
value: DeckConfig_Config_AnswerAction.ANSWER_GOOD,
},
{
label: tr.deckConfigAnswerHard(),
value: DeckConfig_Config_AnswerAction.ANSWER_HARD,
},
{
label: tr.deckConfigShowReminder(),
value: DeckConfig_Config_AnswerAction.SHOW_REMINDER,
},
];
}

View file

@ -22,6 +22,7 @@ const i18n = setupI18n({
ModuleName.ACTIONS,
ModuleName.DECK_CONFIG,
ModuleName.KEYBOARD,
ModuleName.STUDYING,
],
});