Merge branch 'ankitects:main' into main

This commit is contained in:
Matthias M 2021-05-24 10:59:02 +02:00
commit 12e69cb668
39 changed files with 623 additions and 372 deletions

View file

@ -23,10 +23,13 @@ deck-config-new-limit-tooltip =
deck-config-review-limit-tooltip = deck-config-review-limit-tooltip =
The maximum number of review cards to show in a day, The maximum number of review cards to show in a day,
if cards are ready for review. if cards are ready for review.
deck-config-limit-deck-v3 =
When studying a deck that has subdecks inside it, the limits set on each
subdeck control the maximum number of cards drawn from that particular deck.
The selected deck's limits control the total cards that will be shown.
## Learning section ## New Cards section
deck-config-learning-title = Learning
deck-config-learning-steps = Learning steps deck-config-learning-steps = Learning steps
# Please don't translate `5m` or `2d` # Please don't translate `5m` or `2d`
-deck-config-delay-hint = Delays can be in minutes (eg `5m`), or days (eg `2d`). -deck-config-delay-hint = Delays can be in minutes (eg `5m`), or days (eg `2d`).
@ -36,14 +39,6 @@ deck-config-learning-steps-tooltip =
The `Good` button will advance to the next step, which is 10 minutes by default. The `Good` button will advance to the next step, which is 10 minutes by default.
Once all steps have been passed, the card will become a review card, and Once all steps have been passed, the card will become a review card, and
will appear on a different day. { -deck-config-delay-hint } will appear on a different day. { -deck-config-delay-hint }
deck-config-interday-step-priority = Interday step priority
deck-config-interday-step-priority-tooltip = When to show (re)learning cards that cross a day boundary.
deck-config-review-mix-mix-with-reviews = Mix with reviews
deck-config-review-mix-show-after-reviews = Show after reviews
deck-config-review-mix-show-before-reviews = Show before reviews
## New Cards section
deck-config-graduating-interval-tooltip = deck-config-graduating-interval-tooltip =
The number of days to wait before showing a card again, after the `Good` button The number of days to wait before showing a card again, after the `Good` button
is pressed on the final learning step. is pressed on the final learning step.
@ -53,48 +48,10 @@ deck-config-easy-interval-tooltip =
deck-config-new-insertion-order = Insertion order deck-config-new-insertion-order = Insertion order
deck-config-new-insertion-order-tooltip = deck-config-new-insertion-order-tooltip =
Controls the position (due #) new cards are assigned when you add new cards. Controls the position (due #) new cards are assigned when you add new cards.
Cards with a lower due # will be shown first when studying. Changing this Cards with a lower due number will be shown first when studying. Changing
option will automatically update the existing position of new cards. this option will automatically update the existing position of new cards.
deck-config-new-insertion-order-sequential = Sequential (oldest cards first) deck-config-new-insertion-order-sequential = Sequential (oldest cards first)
deck-config-new-insertion-order-random = Random deck-config-new-insertion-order-random = Random
deck-config-new-gather-priority = Gather priority
deck-config-new-gather-priority-tooltip =
`Deck`: gathers cards from each subdeck in order, and stops when the
limit of the selected deck has been exceeded. This is faster, and allows you
to prioritize subdecks that are closer to the top.
`Position`: gathers cards from all decks before they are sorted. This
ensures the oldest cards will be shown first, even if the parent limit is
not high enough to see cards from all decks.
deck-config-new-gather-priority-deck = Deck
deck-config-new-gather-priority-position = Position
deck-config-sort-order = Sort order
deck-config-sort-order-tooltip =
This option controls how cards are sorted after they have been gathered.
By default, Anki sorts by card template first, to avoid multiple cards of
the same note from being shown in succession.
deck-config-sort-order-card-template-then-position = Card template, then position
deck-config-sort-order-card-template-then-random = Card template, then random
deck-config-sort-order-position = Position (siblings together)
deck-config-sort-order-random = Random
deck-config-review-priority = Review priority
deck-config-review-priority-tooltip = When to show these cards in relation to review cards.
## Review section
deck-config-review-sort-order-tooltip =
The default order fetches cards from each subdeck in turn, stopping when the limit
of the selected deck has been reached. The gathered cards are then shuffled together,
and shown in due date order. Because gathering stops when the parent limit has been
reached, your child decks should have smaller limits if you wish to see cards from
multiple decks at once.
The other sort options are mainly useful when catching up from a large backlog.
Because they have to sort all the cards first, they can be considerably slower
than the default sort order when many cards are due.
deck-config-sort-order-due-date-then-random = Due date, then random
deck-config-sort-order-ascending-intervals = Ascending intervals
deck-config-sort-order-descending-intervals = Descending intervals
## Lapses section ## Lapses section
@ -118,12 +75,53 @@ deck-config-leech-action-tooltip =
## Burying section ## Burying section
deck-config-bury-title = Burying
deck-config-bury-new-siblings = Bury new siblings until the next day deck-config-bury-new-siblings = Bury new siblings until the next day
deck-config-bury-review-siblings = Bury review siblings until the next day deck-config-bury-review-siblings = Bury review siblings until the next day
deck-config-bury-tooltip = deck-config-bury-tooltip =
Whether other cards of the same note (eg reverse cards, adjacent Whether other cards of the same note (eg reverse cards, adjacent
cloze deletions) will be delayed until the next day. cloze deletions) will be delayed until the next day.
## Ordering section
deck-config-ordering-title = Display Order
deck-config-new-gather-priority = New card gather priority
deck-config-new-gather-priority-tooltip =
`Deck`: gathers cards from each subdeck in order, and stops when the
limit of the selected deck has been exceeded. This is faster, and allows you
to prioritize subdecks that are closer to the top.
`Position`: gathers cards from all decks before they are sorted. This
ensures the oldest cards will be shown first, even if the parent limit is
not high enough to see cards from all decks.
deck-config-new-gather-priority-deck = Deck
deck-config-new-gather-priority-position = Position
deck-config-new-card-sort-order = New card sort order
deck-config-new-card-sort-order-tooltip =
How cards are sorted after they have been gathered. By default, Anki sorts
by card template first, to avoid multiple cards of the same note from being
shown in succession.
deck-config-sort-order-card-template-then-position = Card template, then position
deck-config-sort-order-card-template-then-random = Card template, then random
deck-config-sort-order-position = Position (siblings together)
deck-config-sort-order-random = Random
deck-config-new-review-priority = New/review priority
deck-config-new-review-priority-tooltip = When to show new cards in relation to review cards.
deck-config-interday-step-priority = Interday learning/review priority
deck-config-interday-step-priority-tooltip = When to show (re)learning cards that cross a day boundary.
deck-config-review-mix-mix-with-reviews = Mix with reviews
deck-config-review-mix-show-after-reviews = Show after reviews
deck-config-review-mix-show-before-reviews = Show before reviews
deck-config-review-sort-order = Review sort order
deck-config-review-sort-order-tooltip =
The default order prioritizes cards that have been waiting longest, so that
if you have a backlog of reviews, the longest-waiting ones will appear
first. If you have a large backlog that will take more than a few days to
clear, you may find the alternate sort orders preferable.
deck-config-sort-order-due-date-then-random = Due date, then random
deck-config-sort-order-ascending-intervals = Ascending intervals
deck-config-sort-order-descending-intervals = Descending intervals
## Timer section ## Timer section
deck-config-timer-title = Timer deck-config-timer-title = Timer

View file

@ -789,8 +789,17 @@ class Collection:
except KeyError: except KeyError:
return default return default
def set_config(self, key: str, val: Any) -> OpChanges: def set_config(self, key: str, val: Any, *, undoable: bool = False) -> OpChanges:
return self._backend.set_config_json(key=key, value_json=to_json_bytes(val)) """Set a single config variable to any JSON-serializable value. The config
is currently sent on every sync, so please don't store more than a few
kilobytes in it.
By default, no undo entry will be created, but the existing undo history
will be preserved. Set `undoable=True` to allow the change to be undone;
see undo code for how you can merge multiple undo entries."""
return self._backend.set_config_json(
key=key, value_json=to_json_bytes(val), undoable=undoable
)
def remove_config(self, key: str) -> OpChanges: def remove_config(self, key: str) -> OpChanges:
return self.conf.remove(key) return self.conf.remove(key)
@ -802,14 +811,18 @@ class Collection:
def get_config_bool(self, key: Config.Bool.Key.V) -> bool: def get_config_bool(self, key: Config.Bool.Key.V) -> bool:
return self._backend.get_config_bool(key) return self._backend.get_config_bool(key)
def set_config_bool(self, key: Config.Bool.Key.V, value: bool) -> OpChanges: def set_config_bool(
return self._backend.set_config_bool(key=key, value=value) self, key: Config.Bool.Key.V, value: bool, *, undoable: bool = False
) -> OpChanges:
return self._backend.set_config_bool(key=key, value=value, undoable=undoable)
def get_config_string(self, key: Config.String.Key.V) -> str: def get_config_string(self, key: Config.String.Key.V) -> str:
return self._backend.get_config_string(key) return self._backend.get_config_string(key)
def set_config_string(self, key: Config.String.Key.V, value: str) -> OpChanges: def set_config_string(
return self._backend.set_config_string(key=key, value=value) self, key: Config.String.Key.V, value: str, undoable: bool = False
) -> OpChanges:
return self._backend.set_config_string(key=key, value=value, undoable=undoable)
# Stats # Stats
########################################################################## ##########################################################################

View file

@ -45,7 +45,10 @@ class ConfigManager:
def set(self, key: str, val: Any) -> None: def set(self, key: str, val: Any) -> None:
self.col._backend.set_config_json_no_undo( self.col._backend.set_config_json_no_undo(
key=key, value_json=to_json_bytes(val) key=key,
value_json=to_json_bytes(val),
# this argument is ignored
undoable=True,
) )
def remove(self, key: str) -> OpChanges: def remove(self, key: str) -> OpChanges:

View file

@ -170,7 +170,8 @@ class ModelManager:
return self.get(NotetypeId(self.all_names_and_ids()[0].id)) return self.get(NotetypeId(self.all_names_and_ids()[0].id))
def setCurrent(self, m: NotetypeDict) -> None: def setCurrent(self, m: NotetypeDict) -> None:
self.col.conf["curModel"] = m["id"] """Legacy. The current notetype is now updated on note add."""
self.col.set_config("curModel", m["id"])
# Retrieving and creating models # Retrieving and creating models
############################################################# #############################################################

View file

@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Callable, List, Tuple from typing import Any, Callable, List, Tuple
import anki import anki
import anki._backend.backend_pb2 as _pb import anki._backend.backend_pb2 as _pb
@ -40,11 +40,11 @@ def get_stock_notetypes(
m = from_json_bytes(col._backend.get_stock_notetype_legacy(kind)) m = from_json_bytes(col._backend.get_stock_notetype_legacy(kind))
def instance_getter( def instance_getter(
col: anki.collection.Collection, model: Any,
) -> anki.models.NotetypeDict: ) -> Callable[[anki.collection.Collection], anki.models.NotetypeDict]:
return m # pylint:disable=cell-var-from-loop return lambda col: model
out.append((m["name"], instance_getter)) out.append((m["name"], instance_getter(m)))
# add extras from add-ons # add extras from add-ons
for (name_or_func, func) in models: for (name_or_func, func) in models:
if not isinstance(name_or_func, str): if not isinstance(name_or_func, str):

View file

@ -132,6 +132,7 @@ class Browser(QMainWindow):
if changes.browser_table and changes.card: if changes.browser_table and changes.card:
self.card = self.table.get_current_card() self.card = self.table.get_current_card()
self._update_context_actions()
def on_focus_change(self, new: Optional[QWidget], old: Optional[QWidget]) -> None: def on_focus_change(self, new: Optional[QWidget], old: Optional[QWidget]) -> None:
if current_top_level_widget() == self: if current_top_level_widget() == self:
@ -498,21 +499,6 @@ class Browser(QMainWindow):
def selectedNotesAsCards(self) -> Sequence[CardId]: def selectedNotesAsCards(self) -> Sequence[CardId]:
return self.table.get_card_ids_from_selected_note_ids() return self.table.get_card_ids_from_selected_note_ids()
def oneModelNotes(self) -> Sequence[NoteId]:
sf = self.selected_notes()
if not sf:
return []
mods = self.col.db.scalar(
"""
select count(distinct mid) from notes
where id in %s"""
% ids2str(sf)
)
if mods > 1:
showInfo(tr.browsing_please_select_cards_from_only_one())
return []
return sf
def onHelp(self) -> None: def onHelp(self) -> None:
openHelp(HelpPage.BROWSING) openHelp(HelpPage.BROWSING)
@ -528,7 +514,17 @@ where id in %s"""
@skip_if_selection_is_empty @skip_if_selection_is_empty
@ensure_editor_saved @ensure_editor_saved
def onChangeModel(self) -> None: def onChangeModel(self) -> None:
ChangeModel(self, self.oneModelNotes()) ids = self.selected_notes()
if self._is_one_notetype(ids):
ChangeModel(self, ids)
else:
showInfo(tr.browsing_please_select_cards_from_only_one())
def _is_one_notetype(self, ids: Sequence[NoteId]) -> bool:
query = f"select count(distinct mid) from notes where id in {ids2str(ids)}"
if self.col.db.scalar(query) == 1:
return True
return False
def createFilteredDeck(self) -> None: def createFilteredDeck(self) -> None:
search = self.current_search() search = self.current_search()

View file

@ -40,12 +40,11 @@ class SidebarToolbar(QToolBar):
action.setCheckable(True) action.setCheckable(True)
action.setShortcut(f"Alt+{row + 1}") action.setShortcut(f"Alt+{row + 1}")
self._action_group.addAction(action) self._action_group.addAction(action)
saved = self.sidebar.col.get_config("sidebarTool", 0) # always start with first tool
active = saved if saved < len(self._tools) else 0 active = 0
self._action_group.actions()[active].setChecked(True) self._action_group.actions()[active].setChecked(True)
self.sidebar.tool = self._tools[active][0] self.sidebar.tool = self._tools[active][0]
def _on_action_group_triggered(self, action: QAction) -> None: def _on_action_group_triggered(self, action: QAction) -> None:
index = self._action_group.actions().index(action) index = self._action_group.actions().index(action)
self.sidebar.col.set_config("sidebarTool", index)
self.sidebar.tool = self._tools[index][0] self.sidebar.tool = self._tools[index][0]

View file

@ -180,7 +180,8 @@ class Table:
SearchContext(search=last_search, browser=self.browser) SearchContext(search=last_search, browser=self.browser)
) )
self.col.set_config_bool( self.col.set_config_bool(
Config.Bool.BROWSER_TABLE_SHOW_NOTES_MODE, self.is_notes_mode() Config.Bool.BROWSER_TABLE_SHOW_NOTES_MODE,
self.is_notes_mode(),
) )
self._restore_header() self._restore_header()
self._restore_selection(self._toggled_selection) self._restore_selection(self._toggled_selection)

View file

@ -1,6 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from typing import Tuple
from aqt import colors from aqt import colors
from aqt.qt import * from aqt.qt import *
@ -21,6 +22,8 @@ class Switch(QAbstractButton):
radius: int = 10, radius: int = 10,
left_label: str = "", left_label: str = "",
right_label: str = "", right_label: str = "",
left_color: Tuple[str, str] = colors.FLAG4_BG,
right_color: Tuple[str, str] = colors.FLAG3_BG,
parent: QWidget = None, parent: QWidget = None,
) -> None: ) -> None:
super().__init__(parent=parent) super().__init__(parent=parent)
@ -29,9 +32,10 @@ class Switch(QAbstractButton):
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self._left_label = left_label self._left_label = left_label
self._right_label = right_label self._right_label = right_label
self._path_radius = radius self._left_color = left_color
self._knob_radius = radius - self._margin self._right_color = right_color
self._left_position = self._position = self._path_radius + self._margin self._path_radius = radius - self._margin
self._knob_radius = self._left_position = self._position = radius
self._right_position = 3 * self._path_radius + self._margin self._right_position = 3 * self._path_radius + self._margin
@pyqtProperty(int) # type: ignore @pyqtProperty(int) # type: ignore
@ -55,6 +59,11 @@ class Switch(QAbstractButton):
def label(self) -> str: def label(self) -> str:
return self._right_label if self.isChecked() else self._left_label return self._right_label if self.isChecked() else self._left_label
@property
def path_color(self) -> QColor:
color = self._right_color if self.isChecked() else self._left_color
return theme_manager.qcolor(color)
def sizeHint(self) -> QSize: def sizeHint(self) -> QSize:
return QSize( return QSize(
4 * self._path_radius + 2 * self._margin, 4 * self._path_radius + 2 * self._margin,
@ -75,7 +84,7 @@ class Switch(QAbstractButton):
self._paint_label(painter) self._paint_label(painter)
def _paint_path(self, painter: QPainter) -> None: def _paint_path(self, painter: QPainter) -> None:
painter.setBrush(QBrush(theme_manager.qcolor(colors.FRAME_BG))) painter.setBrush(QBrush(self.path_color))
rectangle = QRectF( rectangle = QRectF(
self._margin, self._margin,
self._margin, self._margin,
@ -87,19 +96,23 @@ class Switch(QAbstractButton):
def _current_knob_rectangle(self) -> QRectF: def _current_knob_rectangle(self) -> QRectF:
return QRectF( return QRectF(
self.position - self._knob_radius, # type: ignore self.position - self._knob_radius, # type: ignore
2 * self._margin, 0,
2 * self._knob_radius, 2 * self._knob_radius,
2 * self._knob_radius, 2 * self._knob_radius,
) )
def _paint_knob(self, painter: QPainter) -> None: def _paint_knob(self, painter: QPainter) -> None:
painter.setBrush(QBrush(theme_manager.qcolor(colors.LINK))) if theme_manager.night_mode:
color = QColor(theme_manager.DARK_MODE_BUTTON_BG_MIDPOINT)
else:
color = theme_manager.qcolor(colors.FRAME_BG)
painter.setBrush(QBrush(color))
painter.drawEllipse(self._current_knob_rectangle()) painter.drawEllipse(self._current_knob_rectangle())
def _paint_label(self, painter: QPainter) -> None: def _paint_label(self, painter: QPainter) -> None:
painter.setPen(QColor("white")) painter.setPen(theme_manager.qcolor(colors.SLIGHTLY_GREY_TEXT))
font = painter.font() font = painter.font()
font.setPixelSize(int(1.5 * self._knob_radius)) font.setPixelSize(int(1.2 * self._knob_radius))
painter.setFont(font) painter.setFont(font)
painter.drawText(self._current_knob_rectangle(), Qt.AlignCenter, self.label) painter.drawText(self._current_knob_rectangle(), Qt.AlignCenter, self.label)

View file

@ -36,6 +36,10 @@ class ThemeManager:
_icon_size = 128 _icon_size = 128
_dark_mode_available: Optional[bool] = None _dark_mode_available: Optional[bool] = None
# Qt applies a gradient to the buttons in dark mode
# from about #505050 to #606060.
DARK_MODE_BUTTON_BG_MIDPOINT = "#555555"
def macos_dark_mode(self) -> bool: def macos_dark_mode(self) -> bool:
"True if the user has night mode on, and has forced native widgets." "True if the user has night mode on, and has forced native widgets."
if not isMac: if not isMac:

View file

@ -988,6 +988,7 @@ message RenameTagsIn {
message SetConfigJsonIn { message SetConfigJsonIn {
string key = 1; string key = 1;
bytes value_json = 2; bytes value_json = 2;
bool undoable = 3;
} }
message StockNotetype { message StockNotetype {
@ -1441,11 +1442,13 @@ message Config {
message SetConfigBoolIn { message SetConfigBoolIn {
Config.Bool.Key key = 1; Config.Bool.Key key = 1;
bool value = 2; bool value = 2;
bool undoable = 3;
} }
message SetConfigStringIn { message SetConfigStringIn {
Config.String.Key key = 1; Config.String.Key key = 1;
string value = 2; string value = 2;
bool undoable = 3;
} }
message RenderMarkdownIn { message RenderMarkdownIn {

View file

@ -65,7 +65,7 @@ impl ConfigService for Backend {
fn set_config_json(&self, input: pb::SetConfigJsonIn) -> Result<pb::OpChanges> { fn set_config_json(&self, input: pb::SetConfigJsonIn) -> Result<pb::OpChanges> {
self.with_col(|col| { self.with_col(|col| {
let val: Value = serde_json::from_slice(&input.value_json)?; let val: Value = serde_json::from_slice(&input.value_json)?;
col.set_config_json(input.key.as_str(), &val) col.set_config_json(input.key.as_str(), &val, input.undoable)
}) })
.map(Into::into) .map(Into::into)
} }
@ -100,7 +100,7 @@ impl ConfigService for Backend {
} }
fn set_config_bool(&self, input: pb::SetConfigBoolIn) -> Result<pb::OpChanges> { fn set_config_bool(&self, input: pb::SetConfigBoolIn) -> Result<pb::OpChanges> {
self.with_col(|col| col.set_config_bool(input.key().into(), input.value)) self.with_col(|col| col.set_config_bool(input.key().into(), input.value, input.undoable))
.map(Into::into) .map(Into::into)
} }
@ -113,7 +113,7 @@ impl ConfigService for Backend {
} }
fn set_config_string(&self, input: pb::SetConfigStringIn) -> Result<pb::OpChanges> { fn set_config_string(&self, input: pb::SetConfigStringIn) -> Result<pb::OpChanges> {
self.with_col(|col| col.set_config_string(input.key().into(), &input.value)) self.with_col(|col| col.set_config_string(input.key().into(), &input.value, input.undoable))
.map(Into::into) .map(Into::into)
} }

View file

@ -70,9 +70,18 @@ impl Collection {
} }
} }
pub fn set_config_bool(&mut self, key: BoolKey, value: bool) -> Result<OpOutput<()>> { pub fn set_config_bool(
&mut self,
key: BoolKey,
value: bool,
undoable: bool,
) -> Result<OpOutput<()>> {
self.transact(Op::UpdateConfig, |col| { self.transact(Op::UpdateConfig, |col| {
col.set_config(key, &value).map(|_| ()) col.set_config(key, &value)?;
if !undoable {
col.clear_current_undo_step_changes();
}
Ok(())
}) })
} }
} }

View file

@ -70,8 +70,19 @@ pub enum SchedulerVersion {
} }
impl Collection { impl Collection {
pub fn set_config_json<T: Serialize>(&mut self, key: &str, val: &T) -> Result<OpOutput<()>> { pub fn set_config_json<T: Serialize>(
self.transact(Op::UpdateConfig, |col| col.set_config(key, val).map(|_| ())) &mut self,
key: &str,
val: &T,
undoable: bool,
) -> Result<OpOutput<()>> {
self.transact(Op::UpdateConfig, |col| {
col.set_config(key, val)?;
if !undoable {
col.clear_current_undo_step_changes();
}
Ok(())
})
} }
pub fn remove_config(&mut self, key: &str) -> Result<OpOutput<()>> { pub fn remove_config(&mut self, key: &str) -> Result<OpOutput<()>> {

View file

@ -23,9 +23,18 @@ impl Collection {
.unwrap_or_else(|| default.to_string()) .unwrap_or_else(|| default.to_string())
} }
pub fn set_config_string(&mut self, key: StringKey, val: &str) -> Result<OpOutput<()>> { pub fn set_config_string(
&mut self,
key: StringKey,
val: &str,
undoable: bool,
) -> Result<OpOutput<()>> {
self.transact(Op::UpdateConfig, |col| { self.transact(Op::UpdateConfig, |col| {
col.set_config_string_inner(key, val).map(|_| ()) col.set_config_string_inner(key, val)?;
if !undoable {
col.clear_current_undo_step_changes();
}
Ok(())
}) })
} }
} }

View file

@ -370,40 +370,52 @@ impl Collection {
} }
} }
// test helpers
#[cfg(test)] #[cfg(test)]
impl Collection { pub mod test_helpers {
pub(crate) fn answer_again(&mut self) { use super::*;
self.answer(|states| states.again, Rating::Again).unwrap()
pub struct PostAnswerState {
pub card_id: CardId,
pub new_state: CardState,
} }
#[allow(dead_code)] impl Collection {
pub(crate) fn answer_hard(&mut self) { pub(crate) fn answer_again(&mut self) -> PostAnswerState {
self.answer(|states| states.hard, Rating::Hard).unwrap() self.answer(|states| states.again, Rating::Again).unwrap()
} }
pub(crate) fn answer_good(&mut self) { #[allow(dead_code)]
self.answer(|states| states.good, Rating::Good).unwrap() pub(crate) fn answer_hard(&mut self) -> PostAnswerState {
} self.answer(|states| states.hard, Rating::Hard).unwrap()
}
pub(crate) fn answer_easy(&mut self) { pub(crate) fn answer_good(&mut self) -> PostAnswerState {
self.answer(|states| states.easy, Rating::Easy).unwrap() self.answer(|states| states.good, Rating::Good).unwrap()
} }
fn answer<F>(&mut self, get_state: F, rating: Rating) -> Result<()> pub(crate) fn answer_easy(&mut self) -> PostAnswerState {
where self.answer(|states| states.easy, Rating::Easy).unwrap()
F: FnOnce(&NextCardStates) -> CardState, }
{
let queued = self.next_card()?.unwrap(); fn answer<F>(&mut self, get_state: F, rating: Rating) -> Result<PostAnswerState>
self.answer_card(&CardAnswer { where
card_id: queued.card.id, F: FnOnce(&NextCardStates) -> CardState,
current_state: queued.next_states.current, {
new_state: get_state(&queued.next_states), let queued = self.next_card()?.unwrap();
rating, let new_state = get_state(&queued.next_states);
answered_at: TimestampMillis::now(), self.answer_card(&CardAnswer {
milliseconds_taken: 0, card_id: queued.card.id,
})?; current_state: queued.next_states.current,
Ok(()) new_state,
rating,
answered_at: TimestampMillis::now(),
milliseconds_taken: 0,
})?;
Ok(PostAnswerState {
card_id: queued.card.id,
new_state,
})
}
} }
} }
@ -416,3 +428,131 @@ fn get_fuzz_seed(card: &Card) -> Option<u64> {
Some((card.id.0 as u64).wrapping_add(card.reps as u64)) Some((card.id.0 as u64).wrapping_add(card.reps as u64))
} }
} }
#[cfg(test)]
mod test {
use super::*;
use crate::{card::CardType, collection::open_test_collection};
fn current_state(col: &mut Collection, card_id: CardId) -> CardState {
col.get_next_card_states(card_id).unwrap().current
}
// make sure the 'current' state for a card matches the
// state we applied to it
#[test]
fn state_application() -> Result<()> {
let mut col = open_test_collection();
if col.timing_today()?.near_cutoff() {
return Ok(());
}
let nt = col.get_notetype_by_name("Basic")?.unwrap();
let mut note = nt.new_note();
col.add_note(&mut note, DeckId(1))?;
// new->learning
let post_answer = col.answer_again();
assert_eq!(
post_answer.new_state,
current_state(&mut col, post_answer.card_id)
);
let card = col.storage.get_card(post_answer.card_id)?.unwrap();
assert_eq!(card.queue, CardQueue::Learn);
assert_eq!(card.remaining_steps, 2);
// learning step
col.storage.db.execute_batch("update cards set due=0")?;
col.clear_study_queues();
let post_answer = col.answer_good();
assert_eq!(
post_answer.new_state,
current_state(&mut col, post_answer.card_id)
);
let card = col.storage.get_card(post_answer.card_id)?.unwrap();
assert_eq!(card.queue, CardQueue::Learn);
assert_eq!(card.remaining_steps, 1);
// graduation
col.storage.db.execute_batch("update cards set due=0")?;
col.clear_study_queues();
let mut post_answer = col.answer_good();
// compensate for shifting the due date
if let CardState::Normal(NormalState::Review(state)) = &mut post_answer.new_state {
state.elapsed_days = 1;
};
assert_eq!(
post_answer.new_state,
current_state(&mut col, post_answer.card_id)
);
let card = col.storage.get_card(post_answer.card_id)?.unwrap();
assert_eq!(card.queue, CardQueue::Review);
assert_eq!(card.interval, 1);
assert_eq!(card.remaining_steps, 0);
// answering a review card again; easy boost
col.storage.db.execute_batch("update cards set due=0")?;
col.clear_study_queues();
let mut post_answer = col.answer_easy();
if let CardState::Normal(NormalState::Review(state)) = &mut post_answer.new_state {
state.elapsed_days = 4;
};
assert_eq!(
post_answer.new_state,
current_state(&mut col, post_answer.card_id)
);
let card = col.storage.get_card(post_answer.card_id)?.unwrap();
assert_eq!(card.queue, CardQueue::Review);
assert_eq!(card.interval, 4);
assert_eq!(card.ease_factor, 2650);
// lapsing it
col.storage.db.execute_batch("update cards set due=0")?;
col.clear_study_queues();
let mut post_answer = col.answer_again();
if let CardState::Normal(NormalState::Relearning(state)) = &mut post_answer.new_state {
state.review.elapsed_days = 1;
};
assert_eq!(
post_answer.new_state,
current_state(&mut col, post_answer.card_id)
);
let card = col.storage.get_card(post_answer.card_id)?.unwrap();
assert_eq!(card.queue, CardQueue::Learn);
assert_eq!(card.ctype, CardType::Relearn);
assert_eq!(card.interval, 1);
assert_eq!(card.ease_factor, 2450);
assert_eq!(card.lapses, 1);
// failed in relearning
col.storage.db.execute_batch("update cards set due=0")?;
col.clear_study_queues();
let mut post_answer = col.answer_again();
if let CardState::Normal(NormalState::Relearning(state)) = &mut post_answer.new_state {
state.review.elapsed_days = 1;
};
assert_eq!(
post_answer.new_state,
current_state(&mut col, post_answer.card_id)
);
let card = col.storage.get_card(post_answer.card_id)?.unwrap();
assert_eq!(card.queue, CardQueue::Learn);
assert_eq!(card.lapses, 1);
// re-graduating
col.storage.db.execute_batch("update cards set due=0")?;
col.clear_study_queues();
let mut post_answer = col.answer_good();
if let CardState::Normal(NormalState::Review(state)) = &mut post_answer.new_state {
state.elapsed_days = 1;
};
assert_eq!(
post_answer.new_state,
current_state(&mut col, post_answer.card_id)
);
let card = col.storage.get_card(post_answer.card_id)?.unwrap();
assert_eq!(card.queue, CardQueue::Review);
assert_eq!(card.interval, 1);
Ok(())
}
}

View file

@ -17,6 +17,7 @@ impl CardStateUpdater {
self.card.remaining_steps = next.learning.remaining_steps; self.card.remaining_steps = next.learning.remaining_steps;
self.card.ctype = CardType::Relearn; self.card.ctype = CardType::Relearn;
self.card.lapses = next.review.lapses; self.card.lapses = next.review.lapses;
self.card.ease_factor = (next.review.ease_factor * 1000.0).round() as u16;
let interval = next let interval = next
.interval_kind() .interval_kind()

View file

@ -19,6 +19,7 @@ impl CardStateUpdater {
self.card.due = (self.timing.days_elapsed + next.scheduled_days) as i32; self.card.due = (self.timing.days_elapsed + next.scheduled_days) as i32;
self.card.ease_factor = (next.ease_factor * 1000.0).round() as u16; self.card.ease_factor = (next.ease_factor * 1000.0).round() as u16;
self.card.lapses = next.lapses; self.card.lapses = next.lapses;
self.card.remaining_steps = 0;
RevlogEntryPartial::maybe_new( RevlogEntryPartial::maybe_new(
current, current,

View file

@ -200,6 +200,9 @@ mod test {
#[test] #[test]
fn undo_counts() -> Result<()> { fn undo_counts() -> Result<()> {
let mut col = open_test_collection(); let mut col = open_test_collection();
if col.timing_today()?.near_cutoff() {
return Ok(());
}
assert_eq!(col.counts(), [0, 0, 0]); assert_eq!(col.counts(), [0, 0, 0]);
add_note(&mut col, true)?; add_note(&mut col, true)?;

View file

@ -197,6 +197,18 @@ mod test {
use super::*; use super::*;
// test helper
impl SchedTimingToday {
/// Check if less than 25 minutes until the rollover
pub fn near_cutoff(&self) -> bool {
let near = TimestampSecs::now().adding_secs(60 * 25) > self.next_day_at;
if near {
println!("this would fail near the rollover time");
}
near
}
}
// static timezone for tests // static timezone for tests
const AEST_MINS_WEST: i32 = -600; const AEST_MINS_WEST: i32 = -600;

View file

@ -182,6 +182,12 @@ impl UndoManager {
self.end_step(); self.end_step();
self.counter self.counter
} }
fn clear_current_changes(&mut self) {
if let Some(op) = &mut self.current_step {
op.changes.clear();
}
}
} }
impl Collection { impl Collection {
@ -255,6 +261,12 @@ impl Collection {
self.state.undo.save(item.into()); self.state.undo.save(item.into());
} }
/// Forget any recorded changes in the current transaction, allowing
/// a minor change like a config update to bypass undo.
pub(crate) fn clear_current_undo_step_changes(&mut self) {
self.state.undo.clear_current_changes()
}
pub(crate) fn current_undo_op(&self) -> Option<&UndoableOp> { pub(crate) fn current_undo_op(&self) -> Option<&UndoableOp> {
self.state.undo.current_op() self.state.undo.current_op()
} }

View file

@ -3,13 +3,10 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="typescript"> <script lang="typescript">
import type { Modifier } from "lib/shortcuts";
import { onDestroy } from "svelte"; import { onDestroy } from "svelte";
import { registerShortcut, getPlatformString } from "lib/shortcuts"; import { registerShortcut, getPlatformString } from "lib/shortcuts";
export let shortcut: string; export let shortcut: string;
export let optionalModifiers: Modifier[] | undefined = [];
const shortcutLabel = getPlatformString(shortcut); const shortcutLabel = getPlatformString(shortcut);
@ -17,14 +14,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
function createShortcut({ detail }: CustomEvent): void { function createShortcut({ detail }: CustomEvent): void {
const mounted: HTMLButtonElement = detail.button; const mounted: HTMLButtonElement = detail.button;
deregister = registerShortcut( deregister = registerShortcut((event: KeyboardEvent) => {
(event: KeyboardEvent) => { mounted.dispatchEvent(new MouseEvent("click", event));
mounted.dispatchEvent(new MouseEvent("click", event)); event.preventDefault();
event.preventDefault(); }, shortcut);
},
shortcut,
optionalModifiers
);
} }
onDestroy(() => deregister()); onDestroy(() => deregister());

View file

@ -0,0 +1,27 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import * as tr from "lib/i18n";
import CheckBox from "./CheckBox.svelte";
import type { DeckOptionsState } from "./lib";
export let state: DeckOptionsState;
let config = state.currentConfig;
let defaults = state.defaults;
</script>
<h2>{tr.deckConfigBuryTitle()}</h2>
<CheckBox
label={tr.deckConfigBuryNewSiblings()}
tooltip={tr.deckConfigBuryTooltip()}
defaultValue={defaults.buryNew}
bind:value={$config.buryNew} />
<CheckBox
label={tr.deckConfigBuryReviewSiblings()}
tooltip={tr.deckConfigBuryTooltip()}
defaultValue={defaults.buryReviews}
bind:value={$config.buryReviews} />

View file

@ -4,10 +4,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import DailyLimits from "./DailyLimits.svelte"; import DailyLimits from "./DailyLimits.svelte";
import LearningOptions from "./LearningOptions.svelte"; import DisplayOrder from "./DisplayOrder.svelte";
import NewOptions from "./NewOptions.svelte"; import NewOptions from "./NewOptions.svelte";
import AdvancedOptions from "./AdvancedOptions.svelte"; import AdvancedOptions from "./AdvancedOptions.svelte";
import ReviewOptions from "./ReviewOptions.svelte"; import BuryOptions from "./BuryOptions.svelte";
import LapseOptions from "./LapseOptions.svelte"; import LapseOptions from "./LapseOptions.svelte";
import GeneralOptions from "./GeneralOptions.svelte"; import GeneralOptions from "./GeneralOptions.svelte";
import Addons from "./Addons.svelte"; import Addons from "./Addons.svelte";
@ -36,10 +36,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<div class="outer"> <div class="outer">
<DailyLimits {state} /> <DailyLimits {state} />
<LearningOptions {state} />
<NewOptions {state} /> <NewOptions {state} />
<ReviewOptions {state} />
<LapseOptions {state} /> <LapseOptions {state} />
<BuryOptions {state} />
{#if state.v3Scheduler}
<DisplayOrder {state} />
{/if}
<GeneralOptions {state} /> <GeneralOptions {state} />
<Addons {state} /> <Addons {state} />
<AdvancedOptions {state} /> <AdvancedOptions {state} />

View file

@ -12,6 +12,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let defaults = state.defaults; let defaults = state.defaults;
let parentLimits = state.parentLimits; let parentLimits = state.parentLimits;
const v3Extra = state.v3Scheduler ? "\n\n" + tr.deckConfigLimitDeckV3() : "";
$: newCardsGreaterThanParent = $: newCardsGreaterThanParent =
!state.v3Scheduler && $config.newPerDay > $parentLimits.newCards !state.v3Scheduler && $config.newPerDay > $parentLimits.newCards
? tr.deckConfigDailyLimitWillBeCapped({ cards: $parentLimits.newCards }) ? tr.deckConfigDailyLimitWillBeCapped({ cards: $parentLimits.newCards })
@ -30,7 +32,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<SpinBox <SpinBox
label={tr.schedulingNewCardsday()} label={tr.schedulingNewCardsday()}
tooltip={tr.deckConfigNewLimitTooltip()} tooltip={tr.deckConfigNewLimitTooltip() + v3Extra}
min={0} min={0}
warnings={[newCardsGreaterThanParent]} warnings={[newCardsGreaterThanParent]}
defaultValue={defaults.newPerDay} defaultValue={defaults.newPerDay}
@ -38,7 +40,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<SpinBox <SpinBox
label={tr.schedulingMaximumReviewsday()} label={tr.schedulingMaximumReviewsday()}
tooltip={tr.deckConfigReviewLimitTooltip()} tooltip={tr.deckConfigReviewLimitTooltip() + v3Extra}
min={0} min={0}
warnings={[reviewsTooLow]} warnings={[reviewsTooLow]}
defaultValue={defaults.reviewsPerDay} defaultValue={defaults.reviewsPerDay}

View file

@ -0,0 +1,68 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import * as tr from "lib/i18n";
import EnumSelector from "./EnumSelector.svelte";
import type { DeckOptionsState } from "./lib";
import { reviewMixChoices } from "./strings";
export let state: DeckOptionsState;
let config = state.currentConfig;
let defaults = state.defaults;
const newGatherPriorityChoices = [
tr.deckConfigNewGatherPriorityDeck(),
tr.deckConfigNewGatherPriorityPosition(),
];
const newSortOrderChoices = [
tr.deckConfigSortOrderCardTemplateThenPosition(),
tr.deckConfigSortOrderCardTemplateThenRandom(),
tr.deckConfigSortOrderPosition(),
tr.deckConfigSortOrderRandom(),
];
const reviewOrderChoices = [
tr.deckConfigSortOrderDueDateThenRandom(),
tr.deckConfigSortOrderAscendingIntervals(),
tr.deckConfigSortOrderDescendingIntervals(),
];
</script>
<h2>{tr.deckConfigOrderingTitle()}</h2>
<EnumSelector
label={tr.deckConfigNewGatherPriority()}
tooltip={tr.deckConfigNewGatherPriorityTooltip()}
choices={newGatherPriorityChoices}
defaultValue={defaults.newCardGatherPriority}
bind:value={$config.newCardGatherPriority} />
<EnumSelector
label={tr.deckConfigNewCardSortOrder()}
tooltip={tr.deckConfigNewCardSortOrderTooltip()}
choices={newSortOrderChoices}
defaultValue={defaults.newCardSortOrder}
bind:value={$config.newCardSortOrder} />
<EnumSelector
label={tr.deckConfigNewReviewPriority()}
tooltip={tr.deckConfigNewReviewPriorityTooltip()}
choices={reviewMixChoices()}
defaultValue={defaults.newMix}
bind:value={$config.newMix} />
<EnumSelector
label={tr.deckConfigInterdayStepPriority()}
tooltip={tr.deckConfigInterdayStepPriorityTooltip()}
choices={reviewMixChoices()}
defaultValue={defaults.interdayLearningMix}
bind:value={$config.interdayLearningMix} />
<EnumSelector
label={tr.deckConfigReviewSortOrder()}
tooltip={tr.deckConfigReviewSortOrderTooltip()}
choices={reviewOrderChoices}
defaultValue={defaults.reviewOrder}
bind:value={$config.reviewOrder} />

View file

@ -1,33 +0,0 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import * as tr from "lib/i18n";
import StepsInput from "./StepsInput.svelte";
import EnumSelector from "./EnumSelector.svelte";
import type { DeckOptionsState } from "./lib";
import { reviewMixChoices } from "./strings";
export let state: DeckOptionsState;
let config = state.currentConfig;
let defaults = state.defaults;
</script>
<h2>{tr.deckConfigLearningTitle()}</h2>
<StepsInput
label={tr.deckConfigLearningSteps()}
tooltip={tr.deckConfigLearningStepsTooltip()}
defaultValue={defaults.learnSteps}
value={$config.learnSteps}
on:changed={(evt) => ($config.learnSteps = evt.detail.value)} />
{#if state.v3Scheduler}
<EnumSelector
label={tr.deckConfigInterdayStepPriority()}
tooltip={tr.deckConfigInterdayStepPriorityTooltip()}
choices={reviewMixChoices()}
defaultValue={defaults.interdayLearningMix}
bind:value={$config.interdayLearningMix} />
{/if}

View file

@ -5,10 +5,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<script lang="ts"> <script lang="ts">
import * as tr from "lib/i18n"; import * as tr from "lib/i18n";
import SpinBox from "./SpinBox.svelte"; import SpinBox from "./SpinBox.svelte";
import CheckBox from "./CheckBox.svelte"; import StepsInput from "./StepsInput.svelte";
import EnumSelector from "./EnumSelector.svelte"; import EnumSelector from "./EnumSelector.svelte";
import type { DeckOptionsState } from "./lib"; import type { DeckOptionsState } from "./lib";
import { reviewMixChoices } from "./strings";
export let state: DeckOptionsState; export let state: DeckOptionsState;
let config = state.currentConfig; let config = state.currentConfig;
@ -18,16 +17,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
tr.deckConfigNewInsertionOrderSequential(), tr.deckConfigNewInsertionOrderSequential(),
tr.deckConfigNewInsertionOrderRandom(), tr.deckConfigNewInsertionOrderRandom(),
]; ];
const newGatherPriorityChoices = [
tr.deckConfigNewGatherPriorityDeck(),
tr.deckConfigNewGatherPriorityPosition(),
];
const newSortOrderChoices = [
tr.deckConfigSortOrderCardTemplateThenPosition(),
tr.deckConfigSortOrderCardTemplateThenRandom(),
tr.deckConfigSortOrderPosition(),
tr.deckConfigSortOrderRandom(),
];
let stepsExceedGraduatingInterval: string; let stepsExceedGraduatingInterval: string;
$: { $: {
@ -48,6 +37,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<h2>{tr.schedulingNewCards()}</h2> <h2>{tr.schedulingNewCards()}</h2>
<StepsInput
label={tr.deckConfigLearningSteps()}
tooltip={tr.deckConfigLearningStepsTooltip()}
defaultValue={defaults.learnSteps}
value={$config.learnSteps}
on:changed={(evt) => ($config.learnSteps = evt.detail.value)} />
<SpinBox <SpinBox
label={tr.schedulingGraduatingInterval()} label={tr.schedulingGraduatingInterval()}
tooltip={tr.deckConfigGraduatingIntervalTooltip()} tooltip={tr.deckConfigGraduatingIntervalTooltip()}
@ -62,38 +58,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
defaultValue={defaults.graduatingIntervalEasy} defaultValue={defaults.graduatingIntervalEasy}
bind:value={$config.graduatingIntervalEasy} /> bind:value={$config.graduatingIntervalEasy} />
{#if state.v3Scheduler} <EnumSelector
<EnumSelector label={tr.deckConfigNewInsertionOrder()}
label={tr.deckConfigNewInsertionOrder()} tooltip={tr.deckConfigNewInsertionOrderTooltip()}
tooltip={tr.deckConfigNewInsertionOrderTooltip()} choices={newInsertOrderChoices}
choices={newInsertOrderChoices} defaultValue={defaults.newCardInsertOrder}
defaultValue={defaults.newCardInsertOrder} bind:value={$config.newCardInsertOrder} />
bind:value={$config.newCardInsertOrder} />
<EnumSelector
label={tr.deckConfigNewGatherPriority()}
tooltip={tr.deckConfigNewGatherPriorityTooltip()}
choices={newGatherPriorityChoices}
defaultValue={defaults.newCardGatherPriority}
bind:value={$config.newCardGatherPriority} />
<EnumSelector
label={tr.deckConfigSortOrder()}
tooltip={tr.deckConfigSortOrderTooltip()}
choices={newSortOrderChoices}
defaultValue={defaults.newCardSortOrder}
bind:value={$config.newCardSortOrder} />
<EnumSelector
label={tr.deckConfigReviewPriority()}
tooltip={tr.deckConfigReviewPriorityTooltip()}
choices={reviewMixChoices()}
defaultValue={defaults.newMix}
bind:value={$config.newMix} />
{/if}
<CheckBox
label={tr.deckConfigBuryNewSiblings()}
tooltip={tr.deckConfigBuryTooltip()}
defaultValue={defaults.buryNew}
bind:value={$config.buryNew} />

View file

@ -6,7 +6,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import * as tr from "lib/i18n"; import * as tr from "lib/i18n";
import { revertIcon } from "./icons"; import { revertIcon } from "./icons";
import { createEventDispatcher } from "svelte"; import { createEventDispatcher } from "svelte";
import { isEqual, cloneDeep } from "lodash-es"; import { isEqual as isEqualLodash, cloneDeep } from "lodash-es";
// import { onMount } from "svelte"; // import { onMount } from "svelte";
// import Tooltip from "bootstrap/js/dist/tooltip"; // import Tooltip from "bootstrap/js/dist/tooltip";
@ -26,6 +26,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
function isEqual(a: unknown, b: unknown): boolean {
if (typeof a === "number" && typeof b === "number") {
// round to .01 precision before comparing,
// so the values coming out of the UI match
// the originals
return isEqualLodash(Math.round(a * 100) / 100, Math.round(b * 100) / 100);
} else {
return isEqualLodash(a, b);
}
}
let modified: boolean; let modified: boolean;
$: modified = !isEqual(value, defaultValue); $: modified = !isEqual(value, defaultValue);

View file

@ -1,39 +0,0 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import * as tr from "lib/i18n";
import CheckBox from "./CheckBox.svelte";
import EnumSelector from "./EnumSelector.svelte";
import type { DeckOptionsState } from "./lib";
export let state: DeckOptionsState;
let config = state.currentConfig;
let defaults = state.defaults;
const reviewOrderChoices = [
tr.deckConfigSortOrderDueDateThenRandom(),
tr.deckConfigSortOrderAscendingIntervals(),
tr.deckConfigSortOrderDescendingIntervals(),
];
</script>
<div>
<h2>{tr.schedulingReviews()}</h2>
{#if state.v3Scheduler}
<EnumSelector
label={tr.deckConfigSortOrder()}
tooltip={tr.deckConfigReviewSortOrderTooltip()}
choices={reviewOrderChoices}
defaultValue={defaults.reviewOrder}
bind:value={$config.reviewOrder} />
{/if}
<CheckBox
label={tr.deckConfigBuryReviewSiblings()}
tooltip={tr.deckConfigBuryTooltip()}
defaultValue={defaults.buryReviews}
bind:value={$config.buryReviews} />
</div>

View file

@ -46,10 +46,10 @@ html {
// the default code color in tooltips is difficult to read; we'll probably // the default code color in tooltips is difficult to read; we'll probably
// want to add more of our own styling in the future // want to add more of our own styling in the future
code { code {
color: var(--flag1-bg); color: #ffaaaa;
} }
// override the default down arrow colour in <select> elements // override the default down arrow colour in <select> elements
.night-mode select { .night-mode select {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23FFFFFF' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e") background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23FFFFFF' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e");
} }

View file

@ -41,11 +41,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
</script> </script>
<WithShortcut <WithShortcut shortcut={'Control+Alt?+Shift+C'} let:createShortcut let:shortcutLabel>
shortcut="Control+Shift+KeyC"
optionalModifiers={['Alt']}
let:createShortcut
let:shortcutLabel>
<IconButton <IconButton
tooltip={`${tr.editingClozeDeletion()} (${shortcutLabel})`} tooltip={`${tr.editingClozeDeletion()} (${shortcutLabel})`}
on:click={onCloze} on:click={onCloze}

View file

@ -36,7 +36,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<ButtonGroup {api}> <ButtonGroup {api}>
<ButtonGroupItem> <ButtonGroupItem>
<WithShortcut shortcut="F7" let:createShortcut let:shortcutLabel> <WithShortcut shortcut={'F7'} let:createShortcut let:shortcutLabel>
<IconButton <IconButton
class="forecolor" class="forecolor"
tooltip={appendInParentheses(tr.editingSetForegroundColor(), shortcutLabel)} tooltip={appendInParentheses(tr.editingSetForegroundColor(), shortcutLabel)}
@ -48,7 +48,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</ButtonGroupItem> </ButtonGroupItem>
<ButtonGroupItem> <ButtonGroupItem>
<WithShortcut shortcut="F8" let:createShortcut let:shortcutLabel> <WithShortcut shortcut={'F8'} let:createShortcut let:shortcutLabel>
<ColorPicker <ColorPicker
tooltip={appendInParentheses(tr.editingChangeColor(), shortcutLabel)} tooltip={appendInParentheses(tr.editingChangeColor(), shortcutLabel)}
on:change={setWithCurrentColor} on:change={setWithCurrentColor}

View file

@ -26,7 +26,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<ButtonGroup {api}> <ButtonGroup {api}>
<ButtonGroupItem> <ButtonGroupItem>
<WithShortcut shortcut="Control+KeyB" let:createShortcut let:shortcutLabel> <WithShortcut shortcut={'Control+B'} let:createShortcut let:shortcutLabel>
<WithState <WithState
key="bold" key="bold"
update={() => document.queryCommandState('bold')} update={() => document.queryCommandState('bold')}
@ -47,7 +47,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</ButtonGroupItem> </ButtonGroupItem>
<ButtonGroupItem> <ButtonGroupItem>
<WithShortcut shortcut="Control+KeyI" let:createShortcut let:shortcutLabel> <WithShortcut shortcut={'Control+I'} let:createShortcut let:shortcutLabel>
<WithState <WithState
key="italic" key="italic"
update={() => document.queryCommandState('italic')} update={() => document.queryCommandState('italic')}
@ -68,7 +68,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</ButtonGroupItem> </ButtonGroupItem>
<ButtonGroupItem> <ButtonGroupItem>
<WithShortcut shortcut="Control+KeyU" let:createShortcut let:shortcutLabel> <WithShortcut shortcut={'Control+U'} let:createShortcut let:shortcutLabel>
<WithState <WithState
key="underline" key="underline"
update={() => document.queryCommandState('underline')} update={() => document.queryCommandState('underline')}
@ -89,10 +89,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</ButtonGroupItem> </ButtonGroupItem>
<ButtonGroupItem> <ButtonGroupItem>
<WithShortcut <WithShortcut shortcut={'Control+='} let:createShortcut let:shortcutLabel>
shortcut="Control+Shift+Equal"
let:createShortcut
let:shortcutLabel>
<WithState <WithState
key="superscript" key="superscript"
update={() => document.queryCommandState('superscript')} update={() => document.queryCommandState('superscript')}
@ -113,7 +110,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</ButtonGroupItem> </ButtonGroupItem>
<ButtonGroupItem> <ButtonGroupItem>
<WithShortcut shortcut="Control+Equal" let:createShortcut let:shortcutLabel> <WithShortcut shortcut={'Control+Shift+='} let:createShortcut let:shortcutLabel>
<WithState <WithState
key="subscript" key="subscript"
update={() => document.queryCommandState('subscript')} update={() => document.queryCommandState('subscript')}
@ -134,7 +131,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</ButtonGroupItem> </ButtonGroupItem>
<ButtonGroupItem> <ButtonGroupItem>
<WithShortcut shortcut="Control+KeyR" let:createShortcut let:shortcutLabel> <WithShortcut shortcut={'Control+R'} let:createShortcut let:shortcutLabel>
<IconButton <IconButton
tooltip={appendInParentheses(tr.editingRemoveFormatting(), shortcutLabel)} tooltip={appendInParentheses(tr.editingRemoveFormatting(), shortcutLabel)}
on:click={() => { on:click={() => {

View file

@ -25,7 +25,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</ButtonGroupItem> </ButtonGroupItem>
<ButtonGroupItem> <ButtonGroupItem>
<WithShortcut shortcut="Control+KeyL" let:createShortcut let:shortcutLabel> <WithShortcut shortcut={'Control+L'} let:createShortcut let:shortcutLabel>
<LabelButton <LabelButton
disables={false} disables={false}
tooltip={`${tr.editingCustomizeCardTemplates()} (${shortcutLabel})`} tooltip={`${tr.editingCustomizeCardTemplates()} (${shortcutLabel})`}

View file

@ -10,7 +10,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import LabelButton from "components/LabelButton.svelte"; import LabelButton from "components/LabelButton.svelte";
</script> </script>
<WithShortcut shortcut="Control+Shift+KeyP" let:createShortcut let:shortcutLabel> <WithShortcut shortcut={'Control+Shift+P'} let:createShortcut let:shortcutLabel>
<LabelButton <LabelButton
tooltip={tr.browsingPreviewSelectedCard({ val: shortcutLabel })} tooltip={tr.browsingPreviewSelectedCard({ val: shortcutLabel })}
disables={false} disables={false}

View file

@ -36,7 +36,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<ButtonGroup {api}> <ButtonGroup {api}>
<ButtonGroupItem> <ButtonGroupItem>
<WithShortcut shortcut="F3" let:createShortcut let:shortcutLabel> <WithShortcut shortcut={'F3'} let:createShortcut let:shortcutLabel>
<IconButton <IconButton
tooltip={appendInParentheses(tr.editingAttachPicturesaudiovideo(), shortcutLabel)} tooltip={appendInParentheses(tr.editingAttachPicturesaudiovideo(), shortcutLabel)}
on:click={onAttachment} on:click={onAttachment}
@ -47,7 +47,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</ButtonGroupItem> </ButtonGroupItem>
<ButtonGroupItem> <ButtonGroupItem>
<WithShortcut shortcut="F5" let:createShortcut let:shortcutLabel> <WithShortcut shortcut={'F5'} let:createShortcut let:shortcutLabel>
<IconButton <IconButton
tooltip={appendInParentheses(tr.editingRecordAudio(), shortcutLabel)} tooltip={appendInParentheses(tr.editingRecordAudio(), shortcutLabel)}
on:click={onRecord} on:click={onRecord}
@ -69,7 +69,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<DropdownMenu id={menuId}> <DropdownMenu id={menuId}>
<WithShortcut <WithShortcut
shortcut="Control+KeyM, KeyM" shortcut={'Control+M, M'}
let:createShortcut let:createShortcut
let:shortcutLabel> let:shortcutLabel>
<DropdownItem <DropdownItem
@ -81,7 +81,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</WithShortcut> </WithShortcut>
<WithShortcut <WithShortcut
shortcut="Control+KeyM, KeyE" shortcut={'Control+M, E'}
let:createShortcut let:createShortcut
let:shortcutLabel> let:shortcutLabel>
<DropdownItem <DropdownItem
@ -93,7 +93,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</WithShortcut> </WithShortcut>
<WithShortcut <WithShortcut
shortcut="Control+KeyM, KeyC" shortcut={'Control+M, C'}
let:createShortcut let:createShortcut
let:shortcutLabel> let:shortcutLabel>
<DropdownItem <DropdownItem
@ -105,7 +105,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</WithShortcut> </WithShortcut>
<WithShortcut <WithShortcut
shortcut="Control+KeyT, KeyT" shortcut={'Control+T, T'}
let:createShortcut let:createShortcut
let:shortcutLabel> let:shortcutLabel>
<DropdownItem <DropdownItem
@ -117,7 +117,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</WithShortcut> </WithShortcut>
<WithShortcut <WithShortcut
shortcut="Control+KeyT, KeyE" shortcut={'Control+T, E'}
let:createShortcut let:createShortcut
let:shortcutLabel> let:shortcutLabel>
<DropdownItem <DropdownItem
@ -129,7 +129,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</WithShortcut> </WithShortcut>
<WithShortcut <WithShortcut
shortcut="Control+KeyT, KeyM" shortcut={'Control+T, M'}
let:createShortcut let:createShortcut
let:shortcutLabel> let:shortcutLabel>
<DropdownItem <DropdownItem
@ -144,10 +144,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</ButtonGroupItem> </ButtonGroupItem>
<ButtonGroupItem> <ButtonGroupItem>
<WithShortcut <WithShortcut shortcut={'Control+Shift+X'} let:createShortcut let:shortcutLabel>
shortcut="Control+Shift+KeyX"
let:createShortcut
let:shortcutLabel>
<IconButton <IconButton
tooltip={appendInParentheses(tr.editingHtmlEditor(), shortcutLabel)} tooltip={appendInParentheses(tr.editingHtmlEditor(), shortcutLabel)}
on:click={onHtmlEdit} on:click={onHtmlEdit}

View file

@ -66,8 +66,7 @@ function updateFocus(evt: FocusEvent) {
registerShortcut( registerShortcut(
() => document.addEventListener("focusin", updateFocus, { once: true }), () => document.addEventListener("focusin", updateFocus, { once: true }),
"Tab", "Shift?+Tab"
["Shift"]
); );
export function onKeyUp(evt: KeyboardEvent): void { export function onKeyUp(evt: KeyboardEvent): void {

View file

@ -4,8 +4,6 @@ import * as tr from "./i18n";
export type Modifier = "Control" | "Alt" | "Shift" | "Meta"; export type Modifier = "Control" | "Alt" | "Shift" | "Meta";
const modifiers: Modifier[] = ["Control", "Alt", "Shift", "Meta"];
function isApplePlatform(): boolean { function isApplePlatform(): boolean {
return ( return (
window.navigator.platform.startsWith("Mac") || window.navigator.platform.startsWith("Mac") ||
@ -18,10 +16,6 @@ const platformModifiers = isApplePlatform()
? ["Meta", "Alt", "Shift", "Control"] ? ["Meta", "Alt", "Shift", "Control"]
: ["Control", "Alt", "Shift", "OS"]; : ["Control", "Alt", "Shift", "OS"];
function splitKeyCombinationString(keyCombinationString: string): string[][] {
return keyCombinationString.split(", ").map((segment) => segment.split("+"));
}
function modifiersToPlatformString(modifiers: string[]): string { function modifiersToPlatformString(modifiers: string[]): string {
const displayModifiers = isApplePlatform() const displayModifiers = isApplePlatform()
? ["^", "⌥", "⇧", "⌘"] ? ["^", "⌥", "⇧", "⌘"]
@ -36,38 +30,50 @@ function modifiersToPlatformString(modifiers: string[]): string {
return result; return result;
} }
const alphabeticPrefix = "Key"; const keyCodeLookup = {
const numericPrefix = "Digit"; Backspace: 8,
const keyToCharacterMap = { Delete: 46,
Backslash: "\\", Tab: 9,
Backquote: "`", Enter: 13,
BracketLeft: "[", F1: 112,
BrackerRight: "]", F2: 113,
Quote: "'", F3: 114,
Semicolon: ";", F4: 115,
Minus: "-", F5: 116,
Equal: "=", F6: 117,
Comma: ",", F7: 118,
Period: ".", F8: 119,
Slash: "/", F9: 120,
F10: 121,
F11: 122,
F12: 123,
"=": 187,
"-": 189,
"[": 219,
"]": 221,
"\\": 220,
";": 186,
"'": 222,
",": 188,
".": 190,
"/": 191,
"`": 192,
}; };
function keyToPlatformString(key: string): string { function isRequiredModifier(modifier: string): boolean {
if (key.startsWith(alphabeticPrefix)) { return !modifier.endsWith("?");
return key.slice(alphabeticPrefix.length);
} else if (key.startsWith(numericPrefix)) {
return key.slice(numericPrefix.length);
} else if (Object.prototype.hasOwnProperty.call(keyToCharacterMap, key)) {
return keyToCharacterMap[key];
} else {
return key;
}
} }
function toPlatformString(modifiersAndKey: string[]): string { function splitKeyCombinationString(keyCombinationString: string): string[][] {
return `${modifiersToPlatformString( return keyCombinationString.split(", ").map((segment) => segment.split("+"));
modifiersAndKey.slice(0, -1) }
)}${keyToPlatformString(modifiersAndKey[modifiersAndKey.length - 1])}`;
function toPlatformString(keyCombination: string[]): string {
return (
modifiersToPlatformString(
keyCombination.slice(0, -1).filter(isRequiredModifier)
) + keyCombination[keyCombination.length - 1]
);
} }
export function getPlatformString(keyCombinationString: string): string { export function getPlatformString(keyCombinationString: string): string {
@ -76,78 +82,107 @@ export function getPlatformString(keyCombinationString: string): string {
.join(", "); .join(", ");
} }
function checkKey(event: KeyboardEvent, key: string): boolean { function checkKey(event: KeyboardEvent, key: number): boolean {
return event.code === key; return event.which === key;
} }
function checkModifiers( const allModifiers: Modifier[] = ["Control", "Alt", "Shift", "Meta"];
event: KeyboardEvent,
optionalModifiers: Modifier[], function partition<T>(predicate: (t: T) => boolean, items: T[]): [T[], T[]] {
activeModifiers: string[] const trueItems: T[] = [];
): boolean { const falseItems: T[] = [];
return modifiers.reduce(
(matches: boolean, modifier: string, currentIndex: number): boolean => items.forEach((t) => {
const target = predicate(t) ? trueItems : falseItems;
target.push(t);
});
return [trueItems, falseItems];
}
function removeTrailing(modifier: string): string {
return modifier.substring(0, modifier.length - 1);
}
function checkModifiers(event: KeyboardEvent, modifiers: string[]): boolean {
const [requiredModifiers, otherModifiers] = partition(
isRequiredModifier,
modifiers
);
const optionalModifiers = otherModifiers.map(removeTrailing);
return allModifiers.reduce(
(matches: boolean, currentModifier: string, currentIndex: number): boolean =>
matches && matches &&
(optionalModifiers.includes(modifier as Modifier) || (optionalModifiers.includes(currentModifier as Modifier) ||
event.getModifierState(platformModifiers[currentIndex]) === event.getModifierState(platformModifiers[currentIndex]) ===
activeModifiers.includes(modifier)), requiredModifiers.includes(currentModifier)),
true true
); );
} }
function check( const check = (keyCode: number, modifiers: string[]) => (
event: KeyboardEvent, event: KeyboardEvent
optionalModifiers: Modifier[], ): boolean => {
modifiersAndKey: string[] return checkKey(event, keyCode) && checkModifiers(event, modifiers);
): boolean { };
return (
checkKey(event, modifiersAndKey[modifiersAndKey.length - 1]) && function keyToCode(key: string): number {
checkModifiers(event, optionalModifiers, modifiersAndKey.slice(0, -1)) return keyCodeLookup[key] || key.toUpperCase().charCodeAt(0);
);
} }
const shortcutTimeoutMs = 400; function keyCombinationToCheck(
keyCombination: string[]
): (event: KeyboardEvent) => boolean {
const keyCode = keyToCode(keyCombination[keyCombination.length - 1]);
const modifiers = keyCombination.slice(0, -1);
return check(keyCode, modifiers);
}
const GENERAL_KEY = 0;
const NUMPAD_KEY = 3;
function innerShortcut( function innerShortcut(
lastEvent: KeyboardEvent, lastEvent: KeyboardEvent,
callback: (event: KeyboardEvent) => void, callback: (event: KeyboardEvent) => void,
optionalModifiers: Modifier[], ...checks: ((event: KeyboardEvent) => boolean)[]
...keyCombination: string[][]
): void { ): void {
let interval: number; let interval: number;
if (keyCombination.length === 0) { if (checks.length === 0) {
callback(lastEvent); callback(lastEvent);
} else { } else {
const [nextKey, ...restKeys] = keyCombination; const [nextCheck, ...restChecks] = checks;
const handler = (event: KeyboardEvent): void => { const handler = (event: KeyboardEvent): void => {
if (check(event, optionalModifiers, nextKey)) { if (nextCheck(event)) {
innerShortcut(event, callback, optionalModifiers, ...restKeys); innerShortcut(event, callback, ...restChecks);
clearTimeout(interval); clearTimeout(interval);
} else if (
event.location === GENERAL_KEY ||
event.location === NUMPAD_KEY
) {
// Any non-modifier key will cancel the shortcut sequence
document.removeEventListener("keydown", handler);
} }
}; };
interval = setTimeout(
(): void => document.removeEventListener("keydown", handler),
shortcutTimeoutMs
);
document.addEventListener("keydown", handler, { once: true }); document.addEventListener("keydown", handler, { once: true });
} }
} }
export function registerShortcut( export function registerShortcut(
callback: (event: KeyboardEvent) => void, callback: (event: KeyboardEvent) => void,
keyCombinationString: string, keyCombinationString: string
optionalModifiers: Modifier[] = []
): () => void { ): () => void {
const keyCombination = splitKeyCombinationString(keyCombinationString); const [check, ...restChecks] = splitKeyCombinationString(keyCombinationString).map(
const [firstKey, ...restKeys] = keyCombination; keyCombinationToCheck
);
const handler = (event: KeyboardEvent): void => { const handler = (event: KeyboardEvent): void => {
if (check(event, optionalModifiers, firstKey)) { if (check(event)) {
innerShortcut(event, callback, optionalModifiers, ...restKeys); innerShortcut(event, callback, ...restChecks);
} }
}; };