diff --git a/ftl/core/deck-config.ftl b/ftl/core/deck-config.ftl index 04149e2d2..cebd2ab38 100644 --- a/ftl/core/deck-config.ftl +++ b/ftl/core/deck-config.ftl @@ -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 diff --git a/ftl/core/studying.ftl b/ftl/core/studying.ftl index e89436188..8c642d9d5 100644 --- a/ftl/core/studying.ftl +++ b/ftl/core/studying.ftl @@ -56,3 +56,4 @@ studying-minute = [one] { $count } minute. *[other] { $count } minutes. } +studying-answer-time-elapsed = Answer time elapsed diff --git a/proto/anki/deck_config.proto b/proto/anki/deck_config.proto index 0239c9a2f..ca45cff33 100644 --- a/proto/anki/deck_config.proto +++ b/proto/anki/deck_config.proto @@ -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; diff --git a/qt/aqt/main.py b/qt/aqt/main.py index 7f9fd58e2..96511293c 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -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="{}" {}>{}""".format( from aqt.reviewer import Reviewer self.reviewer = Reviewer(self) + self._auto_advance_was_enabled = self.reviewer.auto_advance_enabled # Syncing ########################################################################## diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index f027da5cc..83fafdb81 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -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 diff --git a/qt/aqt/sound.py b/qt/aqt/sound.py index fbd0548f8..1f617e23e 100644 --- a/qt/aqt/sound.py +++ b/qt/aqt/sound.py @@ -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 = [] diff --git a/rslib/src/deckconfig/mod.rs b/rslib/src/deckconfig/mod.rs index 7203aedf1..c2e337ccd 100644 --- a/rslib/src/deckconfig/mod.rs +++ b/rslib/src/deckconfig/mod.rs @@ -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, diff --git a/rslib/src/deckconfig/schema11.rs b/rslib/src/deckconfig/schema11.rs index 3023c5e3a..4bc491919 100644 --- a/rslib/src/deckconfig/schema11.rs +++ b/rslib/src/deckconfig/schema11.rs @@ -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, } +#[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 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 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", }; diff --git a/ts/deck-options/SpinBoxFloatRow.svelte b/ts/deck-options/SpinBoxFloatRow.svelte index 1b6b82dcf..33424c75e 100644 --- a/ts/deck-options/SpinBoxFloatRow.svelte +++ b/ts/deck-options/SpinBoxFloatRow.svelte @@ -13,6 +13,7 @@ export let defaultValue: number; export let min = 0; export let max = 9999; + export let step = 0.01; @@ -21,7 +22,7 @@ - + diff --git a/ts/deck-options/TimerOptions.svelte b/ts/deck-options/TimerOptions.svelte index 6102d1c13..6782a912c 100644 --- a/ts/deck-options/TimerOptions.svelte +++ b/ts/deck-options/TimerOptions.svelte @@ -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 + + + + + openHelpModal( + Object.keys(settings).indexOf("secondsToShowQuestion"), + )} + > + {settings.secondsToShowQuestion.title} + + + + + + + + openHelpModal( + Object.keys(settings).indexOf("secondsToShowAnswer"), + )} + > + {settings.secondsToShowAnswer.title} + + + + + + + + openHelpModal(Object.keys(settings).indexOf("waitForAudio"))} + > + {settings.waitForAudio.title} + + + + + + + + openHelpModal(Object.keys(settings).indexOf("answerAction"))} + > + {settings.answerAction.title} + + + diff --git a/ts/deck-options/choices.ts b/ts/deck-options/choices.ts index b36962f8e..5e6a65cdd 100644 --- a/ts/deck-options/choices.ts +++ b/ts/deck-options/choices.ts @@ -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[] { + 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, + }, + ]; +} diff --git a/ts/deck-options/index.ts b/ts/deck-options/index.ts index e94e2c3f7..7b4ff2c68 100644 --- a/ts/deck-options/index.ts +++ b/ts/deck-options/index.ts @@ -22,6 +22,7 @@ const i18n = setupI18n({ ModuleName.ACTIONS, ModuleName.DECK_CONFIG, ModuleName.KEYBOARD, + ModuleName.STUDYING, ], });