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 =
The maximum number of review cards to show in a day,
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
# Please don't translate `5m` or `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.
Once all steps have been passed, the card will become a review card, and
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 =
The number of days to wait before showing a card again, after the `Good` button
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-tooltip =
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
option will automatically update the existing position of new cards.
Cards with a lower due number will be shown first when studying. Changing
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-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
@ -118,12 +75,53 @@ deck-config-leech-action-tooltip =
## Burying section
deck-config-bury-title = Burying
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-tooltip =
Whether other cards of the same note (eg reverse cards, adjacent
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
deck-config-timer-title = Timer

View file

@ -789,8 +789,17 @@ class Collection:
except KeyError:
return default
def set_config(self, key: str, val: Any) -> OpChanges:
return self._backend.set_config_json(key=key, value_json=to_json_bytes(val))
def set_config(self, key: str, val: Any, *, undoable: bool = False) -> OpChanges:
"""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:
return self.conf.remove(key)
@ -802,14 +811,18 @@ class Collection:
def get_config_bool(self, key: Config.Bool.Key.V) -> bool:
return self._backend.get_config_bool(key)
def set_config_bool(self, key: Config.Bool.Key.V, value: bool) -> OpChanges:
return self._backend.set_config_bool(key=key, value=value)
def set_config_bool(
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:
return self._backend.get_config_string(key)
def set_config_string(self, key: Config.String.Key.V, value: str) -> OpChanges:
return self._backend.set_config_string(key=key, value=value)
def set_config_string(
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
##########################################################################

View file

@ -45,7 +45,10 @@ class ConfigManager:
def set(self, key: str, val: Any) -> None:
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:

View file

@ -170,7 +170,8 @@ class ModelManager:
return self.get(NotetypeId(self.all_names_and_ids()[0].id))
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
#############################################################

View file

@ -3,7 +3,7 @@
from __future__ import annotations
from typing import Callable, List, Tuple
from typing import Any, Callable, List, Tuple
import anki
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))
def instance_getter(
col: anki.collection.Collection,
) -> anki.models.NotetypeDict:
return m # pylint:disable=cell-var-from-loop
model: Any,
) -> Callable[[anki.collection.Collection], anki.models.NotetypeDict]:
return lambda col: model
out.append((m["name"], instance_getter))
out.append((m["name"], instance_getter(m)))
# add extras from add-ons
for (name_or_func, func) in models:
if not isinstance(name_or_func, str):

View file

@ -132,6 +132,7 @@ class Browser(QMainWindow):
if changes.browser_table and changes.card:
self.card = self.table.get_current_card()
self._update_context_actions()
def on_focus_change(self, new: Optional[QWidget], old: Optional[QWidget]) -> None:
if current_top_level_widget() == self:
@ -498,21 +499,6 @@ class Browser(QMainWindow):
def selectedNotesAsCards(self) -> Sequence[CardId]:
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:
openHelp(HelpPage.BROWSING)
@ -528,7 +514,17 @@ where id in %s"""
@skip_if_selection_is_empty
@ensure_editor_saved
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:
search = self.current_search()

View file

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

View file

@ -180,7 +180,8 @@ class Table:
SearchContext(search=last_search, browser=self.browser)
)
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_selection(self._toggled_selection)

View file

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

View file

@ -36,6 +36,10 @@ class ThemeManager:
_icon_size = 128
_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:
"True if the user has night mode on, and has forced native widgets."
if not isMac:

View file

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

View file

@ -65,7 +65,7 @@ impl ConfigService for Backend {
fn set_config_json(&self, input: pb::SetConfigJsonIn) -> Result<pb::OpChanges> {
self.with_col(|col| {
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)
}
@ -100,7 +100,7 @@ impl ConfigService for Backend {
}
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)
}
@ -113,7 +113,7 @@ impl ConfigService for Backend {
}
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)
}

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| {
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 {
pub fn set_config_json<T: Serialize>(&mut self, key: &str, val: &T) -> Result<OpOutput<()>> {
self.transact(Op::UpdateConfig, |col| col.set_config(key, val).map(|_| ()))
pub fn set_config_json<T: Serialize>(
&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<()>> {

View file

@ -23,9 +23,18 @@ impl Collection {
.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| {
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)]
impl Collection {
pub(crate) fn answer_again(&mut self) {
pub mod test_helpers {
use super::*;
pub struct PostAnswerState {
pub card_id: CardId,
pub new_state: CardState,
}
impl Collection {
pub(crate) fn answer_again(&mut self) -> PostAnswerState {
self.answer(|states| states.again, Rating::Again).unwrap()
}
#[allow(dead_code)]
pub(crate) fn answer_hard(&mut self) {
pub(crate) fn answer_hard(&mut self) -> PostAnswerState {
self.answer(|states| states.hard, Rating::Hard).unwrap()
}
pub(crate) fn answer_good(&mut self) {
pub(crate) fn answer_good(&mut self) -> PostAnswerState {
self.answer(|states| states.good, Rating::Good).unwrap()
}
pub(crate) fn answer_easy(&mut self) {
pub(crate) fn answer_easy(&mut self) -> PostAnswerState {
self.answer(|states| states.easy, Rating::Easy).unwrap()
}
fn answer<F>(&mut self, get_state: F, rating: Rating) -> Result<()>
fn answer<F>(&mut self, get_state: F, rating: Rating) -> Result<PostAnswerState>
where
F: FnOnce(&NextCardStates) -> CardState,
{
let queued = self.next_card()?.unwrap();
let new_state = get_state(&queued.next_states);
self.answer_card(&CardAnswer {
card_id: queued.card.id,
current_state: queued.next_states.current,
new_state: get_state(&queued.next_states),
new_state,
rating,
answered_at: TimestampMillis::now(),
milliseconds_taken: 0,
})?;
Ok(())
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))
}
}
#[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.ctype = CardType::Relearn;
self.card.lapses = next.review.lapses;
self.card.ease_factor = (next.review.ease_factor * 1000.0).round() as u16;
let interval = next
.interval_kind()

View file

@ -19,6 +19,7 @@ impl CardStateUpdater {
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.lapses = next.lapses;
self.card.remaining_steps = 0;
RevlogEntryPartial::maybe_new(
current,

View file

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

View file

@ -197,6 +197,18 @@ mod test {
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
const AEST_MINS_WEST: i32 = -600;

View file

@ -182,6 +182,12 @@ impl UndoManager {
self.end_step();
self.counter
}
fn clear_current_changes(&mut self) {
if let Some(op) = &mut self.current_step {
op.changes.clear();
}
}
}
impl Collection {
@ -255,6 +261,12 @@ impl Collection {
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> {
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
-->
<script lang="typescript">
import type { Modifier } from "lib/shortcuts";
import { onDestroy } from "svelte";
import { registerShortcut, getPlatformString } from "lib/shortcuts";
export let shortcut: string;
export let optionalModifiers: Modifier[] | undefined = [];
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 {
const mounted: HTMLButtonElement = detail.button;
deregister = registerShortcut(
(event: KeyboardEvent) => {
deregister = registerShortcut((event: KeyboardEvent) => {
mounted.dispatchEvent(new MouseEvent("click", event));
event.preventDefault();
},
shortcut,
optionalModifiers
);
}, shortcut);
}
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">
import DailyLimits from "./DailyLimits.svelte";
import LearningOptions from "./LearningOptions.svelte";
import DisplayOrder from "./DisplayOrder.svelte";
import NewOptions from "./NewOptions.svelte";
import AdvancedOptions from "./AdvancedOptions.svelte";
import ReviewOptions from "./ReviewOptions.svelte";
import BuryOptions from "./BuryOptions.svelte";
import LapseOptions from "./LapseOptions.svelte";
import GeneralOptions from "./GeneralOptions.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">
<DailyLimits {state} />
<LearningOptions {state} />
<NewOptions {state} />
<ReviewOptions {state} />
<LapseOptions {state} />
<BuryOptions {state} />
{#if state.v3Scheduler}
<DisplayOrder {state} />
{/if}
<GeneralOptions {state} />
<Addons {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 parentLimits = state.parentLimits;
const v3Extra = state.v3Scheduler ? "\n\n" + tr.deckConfigLimitDeckV3() : "";
$: newCardsGreaterThanParent =
!state.v3Scheduler && $config.newPerDay > $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
label={tr.schedulingNewCardsday()}
tooltip={tr.deckConfigNewLimitTooltip()}
tooltip={tr.deckConfigNewLimitTooltip() + v3Extra}
min={0}
warnings={[newCardsGreaterThanParent]}
defaultValue={defaults.newPerDay}
@ -38,7 +40,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<SpinBox
label={tr.schedulingMaximumReviewsday()}
tooltip={tr.deckConfigReviewLimitTooltip()}
tooltip={tr.deckConfigReviewLimitTooltip() + v3Extra}
min={0}
warnings={[reviewsTooLow]}
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">
import * as tr from "lib/i18n";
import SpinBox from "./SpinBox.svelte";
import CheckBox from "./CheckBox.svelte";
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;
@ -18,16 +17,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
tr.deckConfigNewInsertionOrderSequential(),
tr.deckConfigNewInsertionOrderRandom(),
];
const newGatherPriorityChoices = [
tr.deckConfigNewGatherPriorityDeck(),
tr.deckConfigNewGatherPriorityPosition(),
];
const newSortOrderChoices = [
tr.deckConfigSortOrderCardTemplateThenPosition(),
tr.deckConfigSortOrderCardTemplateThenRandom(),
tr.deckConfigSortOrderPosition(),
tr.deckConfigSortOrderRandom(),
];
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>
<StepsInput
label={tr.deckConfigLearningSteps()}
tooltip={tr.deckConfigLearningStepsTooltip()}
defaultValue={defaults.learnSteps}
value={$config.learnSteps}
on:changed={(evt) => ($config.learnSteps = evt.detail.value)} />
<SpinBox
label={tr.schedulingGraduatingInterval()}
tooltip={tr.deckConfigGraduatingIntervalTooltip()}
@ -62,38 +58,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
defaultValue={defaults.graduatingIntervalEasy}
bind:value={$config.graduatingIntervalEasy} />
{#if state.v3Scheduler}
<EnumSelector
<EnumSelector
label={tr.deckConfigNewInsertionOrder()}
tooltip={tr.deckConfigNewInsertionOrderTooltip()}
choices={newInsertOrderChoices}
defaultValue={defaults.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 { revertIcon } from "./icons";
import { createEventDispatcher } from "svelte";
import { isEqual, cloneDeep } from "lodash-es";
import { isEqual as isEqualLodash, cloneDeep } from "lodash-es";
// import { onMount } from "svelte";
// 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();
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;
$: 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
// want to add more of our own styling in the future
code {
color: var(--flag1-bg);
color: #ffaaaa;
}
// override the default down arrow colour in <select> elements
.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>
<WithShortcut
shortcut="Control+Shift+KeyC"
optionalModifiers={['Alt']}
let:createShortcut
let:shortcutLabel>
<WithShortcut shortcut={'Control+Alt?+Shift+C'} let:createShortcut let:shortcutLabel>
<IconButton
tooltip={`${tr.editingClozeDeletion()} (${shortcutLabel})`}
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}>
<ButtonGroupItem>
<WithShortcut shortcut="F7" let:createShortcut let:shortcutLabel>
<WithShortcut shortcut={'F7'} let:createShortcut let:shortcutLabel>
<IconButton
class="forecolor"
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>
<WithShortcut shortcut="F8" let:createShortcut let:shortcutLabel>
<WithShortcut shortcut={'F8'} let:createShortcut let:shortcutLabel>
<ColorPicker
tooltip={appendInParentheses(tr.editingChangeColor(), shortcutLabel)}
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}>
<ButtonGroupItem>
<WithShortcut shortcut="Control+KeyB" let:createShortcut let:shortcutLabel>
<WithShortcut shortcut={'Control+B'} let:createShortcut let:shortcutLabel>
<WithState
key="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>
<WithShortcut shortcut="Control+KeyI" let:createShortcut let:shortcutLabel>
<WithShortcut shortcut={'Control+I'} let:createShortcut let:shortcutLabel>
<WithState
key="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>
<WithShortcut shortcut="Control+KeyU" let:createShortcut let:shortcutLabel>
<WithShortcut shortcut={'Control+U'} let:createShortcut let:shortcutLabel>
<WithState
key="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>
<WithShortcut
shortcut="Control+Shift+Equal"
let:createShortcut
let:shortcutLabel>
<WithShortcut shortcut={'Control+='} let:createShortcut let:shortcutLabel>
<WithState
key="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>
<WithShortcut shortcut="Control+Equal" let:createShortcut let:shortcutLabel>
<WithShortcut shortcut={'Control+Shift+='} let:createShortcut let:shortcutLabel>
<WithState
key="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>
<WithShortcut shortcut="Control+KeyR" let:createShortcut let:shortcutLabel>
<WithShortcut shortcut={'Control+R'} let:createShortcut let:shortcutLabel>
<IconButton
tooltip={appendInParentheses(tr.editingRemoveFormatting(), shortcutLabel)}
on:click={() => {

View file

@ -25,7 +25,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</ButtonGroupItem>
<ButtonGroupItem>
<WithShortcut shortcut="Control+KeyL" let:createShortcut let:shortcutLabel>
<WithShortcut shortcut={'Control+L'} let:createShortcut let:shortcutLabel>
<LabelButton
disables={false}
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";
</script>
<WithShortcut shortcut="Control+Shift+KeyP" let:createShortcut let:shortcutLabel>
<WithShortcut shortcut={'Control+Shift+P'} let:createShortcut let:shortcutLabel>
<LabelButton
tooltip={tr.browsingPreviewSelectedCard({ val: shortcutLabel })}
disables={false}

View file

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

View file

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

View file

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