Merge branch 'main' into qt-widget-gallery

This commit is contained in:
Matthias Metelka 2023-01-11 09:33:19 +01:00 committed by GitHub
commit 4c76bc4b0d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
85 changed files with 1544 additions and 163 deletions

View file

@ -6,7 +6,9 @@ antispam=", at the domain "
headAuthor=$(git log -1 --pretty=format:'%ae')
authorAt=$(echo "$headAuthor" | sed "s/@/$antispam/")
if git log --pretty=format:'%ae' CONTRIBUTORS | grep -i "$headAuthor" > /dev/null; then
if [ $headAuthor = "49699333+dependabot[bot]@users.noreply.github.com" ]; then
echo "Dependabot whitelisted."
elif git log --pretty=format:'%ae' CONTRIBUTORS | grep -i "$headAuthor" > /dev/null; then
echo "Author found in CONTRIBUTORS"
else
echo "All contributors:"

View file

@ -1 +1 @@
2.1.56
2.1.57

8
Cargo.lock generated
View file

@ -347,9 +347,9 @@ checksum = "dfb24e866b15a1af2a1b663f10c6b6b8f397a84aadb828f12e5b289ec23a3a3c"
[[package]]
name = "bzip2"
version = "0.4.3"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6afcd980b5f3a45017c57e57a2fcccbb351cc43a356ce117ef760ef8052b89b0"
checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8"
dependencies = [
"bzip2-sys",
"libc",
@ -3602,9 +3602,9 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c"
[[package]]
name = "tokio"
version = "1.23.0"
version = "1.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eab6d665857cc6ca78d6e80303a02cea7a7851e85dfbd77cbdc09bd129f1ef46"
checksum = "38a54aca0c15d014013256222ba0ebed095673f89345dd79119d912eb561b7a8"
dependencies = [
"autocfg",
"bytes",

View file

@ -13,7 +13,7 @@ camino = "1.1.1"
flate2 = "1.0.25"
sha2 = { version = "0.10.6" }
tar = "0.4.38"
tokio = { version = "1.22.0", features = ["macros", "rt-multi-thread"] }
tokio = { version = "1.23.1", features = ["macros", "rt-multi-thread"] }
workspace-hack = { version = "0.1", path = "../../tools/workspace-hack" }
xz2 = "0.1.7"
zip = "0.6.3"

View file

@ -12,7 +12,7 @@ pub struct SyncSubmodule {
impl BuildAction for SyncSubmodule {
fn command(&self) -> &str {
"git submodule update --init $path"
"git -c protocol.file.allow=always submodule update --init $path"
}
fn files(&mut self, build: &mut impl build::FilesHandle) {

View file

@ -22,6 +22,10 @@ pub fn build_artifacts(args: BuildArtifactsArgs) {
fs::remove_dir_all(&artifacts).unwrap();
}
let bundle_root = args.bundle_root.canonicalize_utf8().unwrap();
let build_folder = bundle_root.join("build");
if build_folder.exists() {
fs::remove_dir_all(&build_folder).unwrap();
}
run_silent(
Command::new(&args.pyoxidizer_bin)
@ -34,7 +38,7 @@ pub fn build_artifacts(args: BuildArtifactsArgs) {
"out/bundle/pyenv",
"--var",
"build",
bundle_root.join("build").as_str(),
build_folder.as_str(),
])
.env("CARGO_MANIFEST_DIR", "qt/bundle")
.env("CARGO_TARGET_DIR", "out/bundle/rust")

View file

@ -29,7 +29,7 @@ $ sudo apt install bash grep findutils curl gcc g++ git rsync ninja-build
## Missing Libraries
If you get errors during startup, try starting with
If you get errors during build or startup, try starting with
QT_DEBUG_PLUGINS=1 ./run
@ -42,6 +42,13 @@ sudo apt install libxcb-icccm4 libxcb-image0 libxcb-keysyms1 \
libxcb-randr0 libxcb-render-util0
```
On some distros such as Arch Linux and Fedora, you may need to install the
`libxcrypt-compat` package if you get an error like this:
```
error while loading shared libraries: libcrypt.so.1: cannot open shared object file: No such file or directory
```
## Audio
To play and record audio during development, install mpv and lame.

@ -1 +1 @@
Subproject commit 2d13bfbe9e60dd365c4300f1e0589100b8b044af
Subproject commit dae74b5783acf77e2ec9a9f764da254ed041563d

View file

@ -48,3 +48,4 @@ preferences-monthly-backups = Monthly backups to keep:
preferences-minutes-between-backups = Minutes between automatic backups:
preferences-reduce-motion = Reduce motion
preferences-reduce-motion-tooltip = Disable various animations and transitions of the user interface
preferences-collapse-toolbar = Hide top bar during review

@ -1 +1 @@
Subproject commit b0336a1f67e8379c2a0e7359bc8e8c2a89719652
Subproject commit 4c7dd7b6cc9d4b666d04eda7cba8a94f68d1660e

View file

@ -0,0 +1,74 @@
syntax = "proto3";
option java_multiple_files = true;
import "anki/generic.proto";
import "anki/scheduler.proto";
package anki.ankidroid;
service AnkidroidService {
rpc SchedTimingTodayLegacy(SchedTimingTodayLegacyRequest)
returns (scheduler.SchedTimingTodayResponse);
rpc LocalMinutesWestLegacy(generic.Int64) returns (generic.Int32);
rpc RunDbCommand(generic.Json) returns (generic.Json);
rpc RunDbCommandProto(generic.Json) returns (DBResponse);
rpc InsertForId(generic.Json) returns (generic.Int64);
rpc RunDbCommandForRowCount(generic.Json) returns (generic.Int64);
rpc FlushAllQueries(generic.Empty) returns (generic.Empty);
rpc FlushQuery(generic.Int32) returns (generic.Empty);
rpc GetNextResultPage(GetNextResultPageRequest) returns (DBResponse);
rpc SetPageSize(generic.Int64) returns (generic.Empty);
rpc GetColumnNamesFromQuery(generic.String) returns (generic.StringList);
rpc GetActiveSequenceNumbers(generic.Empty)
returns (GetActiveSequenceNumbersResponse);
rpc DebugProduceError(generic.String) returns (generic.Empty);
}
message DebugActiveDatabaseSequenceNumbersResponse {
repeated int32 sequence_numbers = 1;
}
message SchedTimingTodayLegacyRequest {
int64 created_secs = 1;
optional sint32 created_mins_west = 2;
int64 now_secs = 3;
sint32 now_mins_west = 4;
sint32 rollover_hour = 5;
}
// We expect in Java: Null, String, Short, Int, Long, Float, Double, Boolean,
// Blob (unused) We get: DbResult (Null, String, i64, f64, Vec<u8>), which
// matches SQLite documentation
message SqlValue {
oneof Data {
string stringValue = 1;
int64 longValue = 2;
double doubleValue = 3;
bytes blobValue = 4;
}
}
message Row {
repeated SqlValue fields = 1;
}
message DbResult {
repeated Row rows = 1;
}
message DBResponse {
DbResult result = 1;
int32 sequenceNumber = 2;
int32 rowCount = 3;
int64 startIndex = 4;
}
message GetNextResultPageRequest {
int32 sequence = 1;
int64 index = 2;
}
message GetActiveSequenceNumbersResponse {
repeated int32 numbers = 1;
}

View file

@ -30,6 +30,7 @@ enum ServiceIndex {
SERVICE_INDEX_CARDS = 14;
SERVICE_INDEX_LINKS = 15;
SERVICE_INDEX_IMPORT_EXPORT = 16;
SERVICE_INDEX_ANKIDROID = 17;
}
message BackendInit {
@ -64,6 +65,7 @@ message BackendError {
IMPORT_ERROR = 16;
DELETED = 17;
CARD_TYPE_ERROR = 18;
ANKIDROID_PANIC_ERROR = 19;
}
// error description, usually localized, suitable for displaying to the user

View file

@ -34,6 +34,9 @@ message OpenCollectionRequest {
string collection_path = 1;
string media_folder_path = 2;
string media_db_path = 3;
// temporary option for AnkiDroid
bool force_schema11 = 99;
}
message CloseCollectionRequest {

View file

@ -309,6 +309,7 @@ class Collection(DeprecatedNamesMixin):
collection_path=self.path,
media_folder_path=media_dir,
media_db_path=media_db,
force_schema11=False,
)
self.db = DBProxy(weakref.proxy(self._backend))
self.db.begin()

View file

@ -194,6 +194,8 @@ for service in anki.backend_pb2.ServiceIndex.DESCRIPTOR.values:
base = service.name.replace("SERVICE_INDEX_", "")
service_pkg = service_modules.get(base)
service_var = "_" + base.replace("_", "") + "SERVICE"
if service_var == "_ANKIDROIDSERVICE":
continue
service_obj = getattr(service_pkg, service_var)
service_index = service.number
render_service(service_obj, service_index)

View file

@ -9,6 +9,7 @@ mypy-protobuf
pip-tools
pylint
pytest
PyChromeDevTools
fluent.syntax
types-decorator
types-flask

View file

@ -368,6 +368,9 @@ protobuf==4.21.9 \
# via
# -r requirements.bundle.txt
# mypy-protobuf
pychromedevtools==0.4 \
--hash=sha256:453f889b11c58fed348206d1b6e91a0bbfe23a319365c586ae462214ecb513ce
# via -r requirements.dev.in
pylint==2.15.5 \
--hash=sha256:3b120505e5af1d06a5ad76b55d8660d44bf0f2fc3c59c2bdd94e39188ee3a4df \
--hash=sha256:c2108037eb074334d9e874dc3c783752cc03d0796c88c9a9af282d0f161a1004
@ -420,7 +423,9 @@ pytoml==0.1.21 \
requests==2.28.1 \
--hash=sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983 \
--hash=sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349
# via -r requirements.bundle.txt
# via
# -r requirements.bundle.txt
# pychromedevtools
send2trash==1.8.0 \
--hash=sha256:d2c24762fd3759860a0aff155e45871447ea58d2be6bdd39b5c8f966a0c99c2d \
--hash=sha256:f20eaadfdb517eaca5ce077640cb261c7d2698385a6a0f072a4a5447fd49fa08
@ -525,6 +530,10 @@ waitress==2.1.2 \
--hash=sha256:7500c9625927c8ec60f54377d590f67b30c8e70ef4b8894214ac6e4cad233d2a \
--hash=sha256:780a4082c5fbc0fde6a2fcfe5e26e6efc1e8f425730863c04085769781f51eba
# via -r requirements.bundle.txt
websocket-client==1.4.2 \
--hash=sha256:d6b06432f184438d99ac1f456eaf22fe1ade524c3dd16e661142dc54e9cba574 \
--hash=sha256:d6e8f90ca8e2dd4e8027c4561adeb9456b54044312dba655e7cae652ceb9ae59
# via pychromedevtools
werkzeug==2.2.2 \
--hash=sha256:7ea2d48322cc7c0f8b3a215ed73eabd7b5d75d0b50e31ab006286ccff9e00b8f \
--hash=sha256:f979ab81f58d7318e064e99c4506445d60135ac5cd2e177a2de0089bfd4c9bd5

View file

@ -1,5 +1,5 @@
pyqt6==6.4.0
pyqt6-qt6==6.4.0
pyqt6-qt6==6.4.2
pyqt6-webengine==6.4.0
pyqt6-webengine-qt6==6.4.0
pyqt6-webengine-qt6==6.4.2
pyqt6_sip==13.4.0

View file

@ -6,11 +6,11 @@ pyqt6==6.4.0 \
# via
# -r requirements.qt6_4.in
# pyqt6-webengine
pyqt6-qt6==6.4.0 \
--hash=sha256:38cfedf942f6982e2492234c4912a6f9ae0d54430313ba32297b7d673adaa11d \
--hash=sha256:9f53036e3c7e0f17eabf6e89689279f3fc4895747b29c0c22d547ba57a087a8b \
--hash=sha256:adee1f98678adebf14cdf4ea1f95cf00b6a644c14e9a79136166d0060de72dfc \
--hash=sha256:fe846c6f89c4ca720ec03c85ec31ac6cc3ffbe8bf5e780f25f99a4cac3372f7c
pyqt6-qt6==6.4.2 \
--hash=sha256:9f07c3c100cb46cca4074965e7494d4df4f0fc016497d5303c1fe135822876e1 \
--hash=sha256:a29b8c858babd523e80c8db5f8fd19792641588ec04eab49af18b7a4423eb99f \
--hash=sha256:c0e91d0275d428496cacff717a9b719c52bfa52b21f124d638b79cc2217bc81e \
--hash=sha256:d19c4e72615762cd6f0b043f23fa5f0b02656091427ce6de1efccd58e10e6a53
# via
# -r requirements.qt6_4.in
# pyqt6
@ -41,11 +41,11 @@ pyqt6-webengine==6.4.0 \
--hash=sha256:7f6cde52b7b8c00ef2a1522ad92cde66f2bd3a3066646efe4ef96a4907b1b1cd \
--hash=sha256:9658919bc1c5279a6fae9e6990448dfe483e136e957e6fb14e8f6265f4e9d1da
# via -r requirements.qt6_4.in
pyqt6-webengine-qt6==6.4.0 \
--hash=sha256:572e7fee6de616191b98dd974ced8bd732e86dc1856c1ada7ad734402e37285c \
--hash=sha256:689127e483ab76744477762ab936de9541e7fc368ab4f4ee463a9099bf8bc5be \
--hash=sha256:971aedd051c77c17c59e724692636a4a0883c70dff3dbd172ae7cfb2fe7ddcc4 \
--hash=sha256:f13b3582c7f170017ecd52ec4c2e735c859316f05820e1bb4a2910c530611af4
pyqt6-webengine-qt6==6.4.2 \
--hash=sha256:071f8c96433c27d10110782dc98cd2d8fee4a9e60fe4ab50e5f2abea48876ae1 \
--hash=sha256:1111a5b580332768b5f4ab08becd639f9298f9a780da59ba2c317f2327e6b191 \
--hash=sha256:4eeeb50a3b92c873996036b168d8b5e42da7db4bef5f7f2de4d863c2958dda5e \
--hash=sha256:e6cbd4193af5d6e7cd82ff2fb04a5d66bc886554bbda00295e9709b0d6447e9d
# via
# -r requirements.qt6_4.in
# pyqt6-webengine

View file

@ -12,7 +12,9 @@ if sys.version_info[0] < 3 or sys.version_info[1] < 9:
try:
"テスト".encode(sys.getfilesystemencoding())
except UnicodeEncodeError as exc:
raise Exception("Anki requires a UTF-8 locale.") from exc
print("Anki requires a UTF-8 locale.")
print("Please Google 'how to change locale on [your Linux distro]'")
sys.exit(1)
from .package import packaged_build_setup

View file

@ -303,7 +303,7 @@ class SidebarTreeView(QTreeView):
) -> None:
if self.current_search and (item := self.model().item_for_index(idx)):
if item.is_highlighted():
brush = QBrush(theme_manager.qcolor(colors.STATE_SUSPENDED))
brush = QBrush(theme_manager.qcolor(colors.HIGHLIGHT_BG))
painter.save()
painter.fillRect(options.rect, brush)
painter.restore()

View file

@ -15,7 +15,9 @@ table {
&:hover {
@include elevation(2);
}
transition: box-shadow 0.2s ease-in-out;
transition: box-shadow var(--transition) ease-in-out;
background: var(--canvas-glass);
backdrop-filter: blur(var(--blur));
}
a.deck {

View file

@ -18,10 +18,6 @@ body {
padding: 0;
}
#innertable {
padding-top: 10px;
}
#middle td[align="center"] {
padding-top: 10px;
position: relative;
@ -30,7 +26,7 @@ body {
button {
min-width: 60px;
white-space: nowrap;
margin: 0.5em;
margin: 9px;
position: relative;
}
@ -51,10 +47,6 @@ button {
font-weight: normal;
}
#ansbut {
margin-bottom: 1em;
}
:focus {
border-color: color(border-focus);
}

View file

@ -3,7 +3,6 @@
#header {
border-bottom: 0;
margin-bottom: 6px;
margin-top: 0;
padding: 9px;
}

View file

@ -6,25 +6,52 @@
@use "sass/elevation" as *;
@use "sass/button-mixins" as button;
#header {
padding-bottom: 4px;
margin-top: -3px;
.header {
height: 41px;
display: grid;
grid-template-columns: repeat(3, 1fr);
align-items: start;
align-content: space-between;
}
.tdcenter {
.left-tray {
justify-self: start;
}
.right-tray {
justify-self: end;
}
.left-tray,
.right-tray {
align-self: start;
display: flex;
flex-direction: row;
align-items: start;
}
.toolbar {
height: 31px;
justify-self: center;
white-space: nowrap;
border-radius: prop(border-radius);
overflow: hidden;
border-bottom-left-radius: prop(border-radius-large);
border-bottom-right-radius: prop(border-radius-large);
@include button.base($with-hover: false, $with-active: false);
overflow: hidden;
padding: 0;
@include elevation(1, $opacity-boost: -0.1);
@include elevation(1, $opacity-boost: -0.08);
&:hover {
@include elevation(2);
// elevated state (deck browser, overview)
body:not(.flat) & {
background: var(--canvas-elevated);
@include elevation(1);
&:hover {
@include elevation(2);
}
}
transition: box-shadow 0.2s ease-in-out;
// glass effect
background: var(--canvas-glass);backdrop-filter: unset;
backdrop-filter: blur(var(--blur));
transition: all var(--transition) ease-in-out;
}
body {
@ -32,6 +59,11 @@ body {
padding: 0;
-webkit-user-select: none;
overflow: hidden;
&.collapsed {
transform: translateY(-100vh);
}
transition: transform var(--transition) ease-in-out;
}
* {
@ -40,12 +72,16 @@ body {
.hitem {
font-weight: bold;
padding: 8px 14px;
padding: 5px 12px;
text-decoration: none;
color: color(fg);
display: inline-block;
@include button.base;
border: none;
body:not(.flat) &,
&:hover {
@include button.base($border: false);
background: var(--canvas-elevated);
}
&:first-child {
padding-left: 18px;
}
@ -75,7 +111,7 @@ body {
display: inline-block;
visibility: visible !important;
animation-timing-function: linear;
transition: all 0.2s ease-in;
transition: all var(--transition) ease-in;
}
#sync-spinner {

View file

@ -15,13 +15,21 @@
body {
color: var(--fg);
background: var(--canvas);
transition: opacity 0.5s ease-out;
transition: opacity var(--transition-medium) ease-out;
margin: 2em;
overscroll-behavior: none;
&:not(.isMac),
&:not(.isMac) * {
@include scrollbar.custom;
}
&.reduced-motion,
&.reduced-motion * {
transition: none !important;
animation: none !important;
}
&.no-blur * {
backdrop-filter: none !important;
}
}
a {

View file

@ -27,3 +27,63 @@ function updateSyncColor(state: SyncState) {
break;
}
}
// Dealing with legacy add-ons that used CSS to absolutely position
// themselves at toolbar edges
function isAbsolutelyPositioned(node: Node): boolean {
if (!(node instanceof HTMLElement)) {
return false;
}
return getComputedStyle(node).position === "absolute";
}
function isLegacyAddonElement(node: Node): boolean {
if (isAbsolutelyPositioned(node)) {
return true;
}
for (const child of node.childNodes) {
if (isAbsolutelyPositioned(child)) {
return true;
}
}
return false;
}
function getElementDimensions(element: HTMLElement): [number, number] {
const widths = [element.offsetWidth];
const heights = [element.offsetHeight];
// Some add-ons inject spans or anchors into the toolbar whose dimensions,
// as reported by the properties above are zero, but still occupy space due
// to their child elements:
for (const child of element.childNodes) {
if (!(child instanceof HTMLElement)) {
continue;
}
widths.push(child.offsetWidth);
heights.push(child.offsetHeight);
}
return [Math.max(...widths), Math.max(...heights)];
}
function moveLegacyAddonsToTray() {
const rightTray = document.getElementsByClassName("right-tray")[0];
const toolbarChildren = document.querySelectorAll<HTMLElement>(".toolbar > *");
const legacyAddonElements: HTMLElement[] = Array.from(toolbarChildren)
.reverse() // restore original add-on load order
.filter(isLegacyAddonElement);
for (const element of legacyAddonElements) {
const wrapperElement = document.createElement("div");
const dimensions = getElementDimensions(element);
element.style.right = "0px"; // remove manual padding
wrapperElement.append(element);
wrapperElement.style.cssText = `\
width: ${dimensions[0]}px; height: ${dimensions[1]}}px;
margin-left: 5px; margin-right: 5px; position: relative;`;
wrapperElement.className = "tray-item tray-item-legacy";
rightTray.append(wrapperElement);
}
}
document.addEventListener("DOMContentLoaded", moveLegacyAddonsToTray);

View file

@ -7,7 +7,7 @@
<x>0</x>
<y>0</y>
<width>640</width>
<height>640</height>
<height>660</height>
</rect>
</property>
<property name="windowTitle">
@ -113,6 +113,13 @@
</property>
</widget>
</item>
<item alignment="Qt::AlignLeft">
<widget class="QCheckBox" name="collapse_toolbar">
<property name="text">
<string>preferences_collapse_toolbar</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="useCurrent">
<item>
@ -666,7 +673,7 @@
</widget>
</widget>
</item>
<item alignment="Qt::AlignCenter">
<item>
<widget class="QLabel" name="label_21">
<property name="text">
<string>preferences_some_settings_will_take_effect_after</string>
@ -696,6 +703,7 @@
<tabstop>ignore_accents_in_search</tabstop>
<tabstop>legacy_import_export</tabstop>
<tabstop>reduce_motion</tabstop>
<tabstop>collapse_toolbar</tabstop>
<tabstop>useCurrent</tabstop>
<tabstop>default_search_text</tabstop>
<tabstop>uiScale</tabstop>

View file

@ -66,6 +66,7 @@ from aqt.qt import sip
from aqt.sync import sync_collection, sync_login
from aqt.taskman import TaskManager
from aqt.theme import Theme, theme_manager
from aqt.toolbar import Toolbar, ToolbarWebView
from aqt.undo import UndoActionsInfo
from aqt.utils import (
HelpPage,
@ -143,6 +144,27 @@ class MainWebView(AnkiWebView):
# currently safe for us to import more than one file at once
return
# Main webview specific event handling
def eventFilter(self, obj, evt):
if handled := super().eventFilter(obj, evt):
return handled
if evt.type() == QEvent.Type.Leave:
if self.mw.pm.collapse_toolbar():
# Expand toolbar when mouse moves above main webview
# and automatically collapse it with delay after mouse leaves
if self.mapFromGlobal(QCursor.pos()).y() < self.geometry().y():
if self.mw.toolbarWeb.collapsed:
self.mw.toolbarWeb.expand()
return True
if evt.type() == QEvent.Type.Enter:
if self.mw.pm.collapse_toolbar():
self.mw.toolbarWeb.hide_timer.start()
return True
return False
class AnkiQt(QMainWindow):
col: Collection
@ -707,10 +729,16 @@ class AnkiQt(QMainWindow):
def _reviewState(self, oldState: MainWindowState) -> None:
self.reviewer.show()
if self.pm.collapse_toolbar():
self.toolbarWeb.collapse()
else:
self.toolbarWeb.flatten()
def _reviewCleanup(self, newState: MainWindowState) -> None:
if newState != "resetRequired" and newState != "review":
self.reviewer.cleanup()
self.toolbarWeb.elevate()
self.toolbarWeb.expand()
# Resetting state
##########################################################################
@ -844,10 +872,8 @@ title="{}" {}>{}</button>""".format(
self.form = aqt.forms.main.Ui_MainWindow()
self.form.setupUi(self)
# toolbar
tweb = self.toolbarWeb = AnkiWebView(title="top toolbar")
tweb.setFocusPolicy(Qt.FocusPolicy.WheelFocus)
tweb.disable_zoom()
self.toolbar = aqt.toolbar.Toolbar(self, tweb)
tweb = self.toolbarWeb = ToolbarWebView(self, title="top toolbar")
self.toolbar = Toolbar(self, tweb)
# main area
self.web = MainWebView(self)
# bottom area
@ -1332,6 +1358,10 @@ title="{}" {}>{}</button>""".format(
window.windowState() ^ Qt.WindowState.WindowFullScreen
)
def collapse_toolbar_if_allowed(self) -> None:
if self.pm.collapse_toolbar() and self.state == "review":
self.toolbarWeb.collapse()
# Auto update
##########################################################################
@ -1382,13 +1412,6 @@ title="{}" {}>{}</button>""".format(
True,
parent=self,
)
self.progress.timer(
12 * 60 * 1000,
self.refresh_certs,
repeat=True,
requiresCollection=False,
parent=self,
)
def onRefreshTimer(self) -> None:
if self.state == "deckBrowser":
@ -1404,15 +1427,6 @@ title="{}" {}>{}</button>""".format(
if elap > minutes * 60:
self.maybe_auto_sync_media()
def refresh_certs(self) -> None:
# The requests library copies the certs into a temporary folder on startup,
# and chokes when the file is later missing due to temp file cleaners.
# Work around the issue by accessing them once every 12 hours.
from requests.certs import where # type: ignore[attr-defined]
with open(where(), "rb") as f:
f.read()
# Backups
##########################################################################

View file

@ -92,6 +92,9 @@ class MPVBase:
"--gapless-audio=no",
]
if is_win:
default_argv += ["--af-add=lavfi=[apad=pad_dur=0.150]"]
def __init__(self, window_id=None, debug=False):
self.window_id = window_id
self.debug = debug
@ -143,7 +146,7 @@ class MPVBase:
--input-unix-socket option.
"""
if is_win:
self._sock_filename = "ankimpv"
self._sock_filename = "ankimpv{}".format(os.getpid())
return
fd, self._sock_filename = tempfile.mkstemp(prefix="mpv.")
os.close(fd)
@ -156,12 +159,11 @@ class MPVBase:
start = time.time()
while self.is_running() and time.time() < start + 10:
time.sleep(0.1)
if is_win:
# named pipe
try:
self._sock = win32file.CreateFile(
r"\\.\pipe\ankimpv",
r"\\.\pipe\{}".format(self._sock_filename),
win32file.GENERIC_READ | win32file.GENERIC_WRITE,
0,
None,

View file

@ -7,6 +7,7 @@ from __future__ import annotations
import os
import sys
from pathlib import Path
def _fix_pywin32() -> None:
@ -49,6 +50,24 @@ def _patch_pkgutil() -> None:
pkgutil.get_data = get_data_custom
def _patch_certifi() -> None:
"""Tell certifi (and thus requests) to use a file in our package folder.
By default it creates a copy of the data in a temporary folder, which then gets
cleaned up by macOS's temp file cleaner."""
import certifi
def where() -> str:
prefix = Path(sys.prefix)
if sys.platform == "darwin":
path = prefix / "../Resources/certifi/cacert.pem"
else:
path = prefix / "lib" / "certifi" / "cacert.pem"
return str(path)
certifi.where = where
def packaged_build_setup() -> None:
if not getattr(sys, "frozen", False):
return
@ -59,6 +78,7 @@ def packaged_build_setup() -> None:
_fix_pywin32()
_patch_pkgutil()
_patch_certifi()
# escape hatch for debugging issues with packaged build startup
if os.getenv("ANKI_STARTUP_REPL"):

View file

@ -208,6 +208,7 @@ class Preferences(QDialog):
def setup_global(self) -> None:
"Setup options global to all profiles."
self.form.reduce_motion.setChecked(self.mw.pm.reduced_motion())
self.form.collapse_toolbar.setChecked(self.mw.pm.collapse_toolbar())
self.form.uiScale.setValue(int(self.mw.pm.uiScale() * 100))
themes = [
tr.preferences_theme_label(theme=theme)
@ -238,7 +239,7 @@ class Preferences(QDialog):
restart_required = True
self.mw.pm.set_reduced_motion(self.form.reduce_motion.isChecked())
self.mw.pm.set_collapse_toolbar(self.form.collapse_toolbar.isChecked())
self.mw.pm.set_legacy_import_export(self.form.legacy_import_export.isChecked())
if restart_required:

View file

@ -524,6 +524,12 @@ create table if not exists profiles
def set_reduced_motion(self, on: bool) -> None:
self.meta["reduced_motion"] = on
def collapse_toolbar(self) -> bool:
return self.meta.get("collapse_toolbar", False)
def set_collapse_toolbar(self, on: bool) -> None:
self.meta["collapse_toolbar"] = on
def last_addon_update_check(self) -> int:
return self.meta.get("last_addon_update_check", 0)

View file

@ -543,6 +543,8 @@ class Reviewer:
self.showContextMenu()
elif url.startswith("play:"):
play_clicked_audio(url, self.card)
elif url.startswith("updateToolbar"):
self.mw.toolbarWeb.update_background_image()
else:
print("unrecognized anki link:", url)

View file

@ -1,6 +1,7 @@
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from anki.utils import is_win
from aqt import colors, props
from aqt.theme import ThemeManager
@ -101,8 +102,10 @@ QMenu::indicator {{
def button_styles(tm: ThemeManager) -> str:
# For some reason, Windows needs a larger padding to look the same
button_pad = 25 if is_win else 15
return f"""
QPushButton {{ padding-left: 15px; padding-right: 15px; }}
QPushButton {{ padding-left: {button_pad}px; padding-right: {button_pad}px; }}
QPushButton,
QTabBar::tab:!selected,
QComboBox:!editable,

View file

@ -2,7 +2,7 @@
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from typing import cast
from aqt import colors
from aqt import colors, props
from aqt.qt import *
from aqt.theme import theme_manager
@ -173,7 +173,7 @@ class Switch(QAbstractButton):
def _animate_toggle(self) -> None:
animation = QPropertyAnimation(self, cast(QByteArray, b"position"), self)
animation.setDuration(100)
animation.setDuration(int(theme_manager.var(props.TRANSITION)))
animation.setStartValue(self.start_position)
animation.setEndValue(self.end_position)
# hide label during animation

View file

@ -25,6 +25,7 @@ from aqt.qt import (
QStyleFactory,
Qt,
qtmajor,
qtminor,
)
@ -175,6 +176,8 @@ class ThemeManager:
classes.append("macos-dark-mode")
if aqt.mw.pm.reduced_motion():
classes.append("reduced-motion")
if qtmajor == 5 and qtminor < 15:
classes.append("no-blur")
return " ".join(classes)
def body_classes_for_card_ord(

View file

@ -2,7 +2,8 @@
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
from typing import Any
import re
from typing import Any, Optional
import aqt
from anki.sync import SyncStatus
@ -25,6 +26,76 @@ class BottomToolbar:
self.toolbar = toolbar
class ToolbarWebView(AnkiWebView):
def __init__(self, mw: aqt.AnkiQt, title: str) -> None:
AnkiWebView.__init__(self, mw, title=title)
self.mw = mw
self.setFocusPolicy(Qt.FocusPolicy.WheelFocus)
self.disable_zoom()
self.collapsed = False
self.web_height = 0
# collapse timer
self.hide_timer = QTimer()
self.hide_timer.setSingleShot(True)
self.hide_timer.setInterval(1000)
qconnect(self.hide_timer.timeout, self.mw.collapse_toolbar_if_allowed)
def eventFilter(self, obj, evt):
if handled := super().eventFilter(obj, evt):
return handled
# prevent collapse if pointer inside
if evt.type() == QEvent.Type.Enter:
self.hide_timer.stop()
self.hide_timer.setInterval(1000)
return True
return False
def _onHeight(self, qvar: Optional[int]) -> None:
super()._onHeight(qvar)
self.web_height = int(qvar)
def collapse(self) -> None:
self.collapsed = True
self.eval("""document.body.classList.add("collapsed"); """)
def expand(self) -> None:
self.collapsed = False
self.eval("""document.body.classList.remove("collapsed"); """)
def flatten(self) -> None:
self.eval("document.body.classList.add('flat'); ")
def elevate(self) -> None:
self.eval(
"""
document.body.classList.remove("flat");
document.body.style.removeProperty("background");
"""
)
def update_background_image(self) -> None:
def set_background(val: str) -> None:
# remove offset from copy
background = re.sub(r"-\d+px ", "0%", val)
# change computedStyle px value back to 100vw
background = re.sub(r"\d+px", "100vw", background)
self.eval(
f"""document.body.style.setProperty("background", '{background}'); """
)
# offset reviewer background by toolbar height
self.mw.web.eval(
f"""document.body.style.setProperty("background-position-y", "-{self.web_height}px"); """
)
self.mw.web.evalWithCallback(
"""window.getComputedStyle(document.body).background; """,
set_background,
)
class Toolbar:
def __init__(self, mw: aqt.AnkiQt, web: AnkiWebView) -> None:
self.mw = mw
@ -32,7 +103,6 @@ class Toolbar:
self.link_handlers: dict[str, Callable] = {
"study": self._studyLinkHandler,
}
self.web.setFixedHeight(30)
self.web.requiresCol = False
def draw(
@ -44,8 +114,13 @@ class Toolbar:
web_context = web_context or TopToolbar(self)
link_handler = link_handler or self._linkHandler
self.web.set_bridge_command(link_handler, web_context)
body = self._body.format(
toolbar_content=self._centerLinks(),
left_tray_content=self._left_tray_content(),
right_tray_content=self._right_tray_content(),
)
self.web.stdHtml(
self._body % self._centerLinks(),
body,
css=["css/toolbar.css"],
js=["js/vendor/jquery.min.js", "js/toolbar.js"],
context=web_context,
@ -134,6 +209,22 @@ class Toolbar:
return "\n".join(links)
# Add-ons
######################################################################
def _left_tray_content(self) -> str:
left_tray_content: list[str] = []
gui_hooks.top_toolbar_will_set_left_tray_content(left_tray_content, self)
return self._process_tray_content(left_tray_content)
def _right_tray_content(self) -> str:
right_tray_content: list[str] = []
gui_hooks.top_toolbar_will_set_right_tray_content(right_tray_content, self)
return self._process_tray_content(right_tray_content)
def _process_tray_content(self, content: list[str]) -> str:
return "\n".join(f"""<div class="tray-item">{item}</div>""" for item in content)
# Sync
######################################################################
@ -195,12 +286,11 @@ class Toolbar:
######################################################################
_body = """
<center id=outer>
<table id=header>
<tr>
<td class=tdcenter align=center>%s</td>
</tr></table>
</center>
<div class="header">
<div class="left-tray">{left_tray_content}</div>
<div class="toolbar">{toolbar_content}</div>
<div class="right-tray">{right_tray_content}</div>
</div>
"""

View file

@ -634,7 +634,6 @@ html {{ {font} }}
from aqt import mw
if qvar is None:
mw.progress.single_shot(1000, mw.reset)
return

View file

@ -132,7 +132,7 @@ fn make_app(kind: DistKind, mut plist: plist::Dictionary, stamp: &Utf8Path) -> R
let path_str = relative_path.to_str().unwrap();
if path_str.contains("libankihelper") {
builder.add_file_macos("libankihelper.dylib", entry)?;
} else if path_str.contains("aqt/data") {
} else if path_str.contains("aqt/data") || path_str.contains("certifi") {
builder.add_file_resources(relative_path.strip_prefix("lib").unwrap(), entry)?;
} else {
if path_str.contains("__pycache__") {

View file

@ -55,6 +55,8 @@ def handle_resource(policy, resource):
for prefix in included_resource_packages:
if resource.package.startswith(prefix):
resource.add_include = True
if resource.package == "certifi":
resource.add_location = "filesystem-relative:lib"
for suffix in excluded_resource_suffixes:
if resource.name.endswith(suffix):
resource.add_include = False

View file

@ -47,10 +47,14 @@ for line in re.split(r"[;\{\}]|\*\/", data):
print("failed to match", line)
continue
# convert variable names to Qt style
var = m.group(1).replace("-", "_").upper()
val = m.group(2)
if reached_props:
# remove trailing ms from time props
val = re.sub(r"^(\d+)ms$", r"\1", val)
if not var in props:
props.setdefault(var, {})["comment"] = comment
props[var]["light"] = val

View file

@ -795,6 +795,36 @@ gui_hooks.webview_did_inject_style_into_page.append(mytest)
links.append(my_link)
""",
),
Hook(
name="top_toolbar_will_set_left_tray_content",
args=["content: list[str]", "top_toolbar: aqt.toolbar.Toolbar"],
doc="""Used to add custom add-on components to the *left* area of Anki's main
window toolbar
'content' is a list of HTML strings added by add-ons which you can append your
own components or elements to. To equip your components with logic and styling
please see `webview_will_set_content` and `webview_did_receive_js_message`.
Please note that Anki's main screen is due to undergo a significant refactor
in the future and, as a result, add-ons subscribing to this hook will likely
require changes to continue working.
""",
),
Hook(
name="top_toolbar_will_set_right_tray_content",
args=["content: list[str]", "top_toolbar: aqt.toolbar.Toolbar"],
doc="""Used to add custom add-on components to the *right* area of Anki's main
window toolbar
'content' is a list of HTML strings added by add-ons which you can append your
own components or elements to. To equip your components with logic and styling
please see `webview_will_set_content` and `webview_did_receive_js_message`.
Please note that Anki's main screen is due to undergo a significant refactor
in the future and, as a result, add-ons subscribing to this hook will likely
require changes to continue working.
""",
),
Hook(
name="top_toolbar_did_redraw",
args=["top_toolbar: aqt.toolbar.Toolbar"],

View file

@ -32,7 +32,7 @@ which = "4.3.0"
[dev-dependencies]
env_logger = "0.10.0"
tokio = { version = "1.22", features = ["macros"] }
tokio = { version = "1.23", features = ["macros"] }
[dependencies.reqwest]
version = "=0.11.3"
@ -54,7 +54,7 @@ ammonia = "3.3.0"
async-trait = "0.1.59"
blake3 = "1.3.3"
bytes = "1.3.0"
chrono = { version = "0.4.23", default-features = false, features = ["std", "clock"] }
chrono = { version = "0.4.19", default-features = false, features = ["std", "clock"] }
coarsetime = "0.1.22"
convert_case = "0.6.0"
dissimilar = "1.0.4"
@ -90,7 +90,7 @@ sha1 = "0.10.5"
snafu = { version = "0.7.3", features = ["backtraces"] }
strum = { version = "0.24.1", features = ["derive"] }
tempfile = "3.3.0"
tokio = { version = "1.22", features = ["fs", "rt-multi-thread"] }
tokio = { version = "1.23", features = ["fs", "rt-multi-thread"] }
tokio-util = { version = "0.7.4", features = ["io"] }
tracing = { version = "0.1.37", features = ["max_level_trace", "release_max_level_debug"] }
tracing-appender = "0.2.2"

View file

@ -20,7 +20,7 @@ linkcheck = { git = "https://github.com/ankitects/linkcheck.git", rev = "2f20798
futures = "0.3.25"
itertools = "0.10.5"
strum = { version = "0.24.1", features = ["derive"] }
tokio = { version = "1.22.0", features = ["full"] }
tokio = { version = "1.23.1", features = ["full"] }
workspace-hack = { version = "0.1", path = "../../tools/workspace-hack" }
[features]

View file

@ -0,0 +1,436 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use std::{
collections::HashMap,
mem::size_of,
sync::{
atomic::{AtomicI32, Ordering},
Mutex,
},
};
use itertools::{
FoldWhile,
FoldWhile::{Continue, Done},
Itertools,
};
use lazy_static::lazy_static;
use rusqlite::ToSql;
use serde_derive::Deserialize;
use crate::{
collection::Collection,
error::Result,
pb::ankidroid::{sql_value::Data, DbResponse, DbResult, Row, SqlValue},
};
/// A pointer to the SqliteStorage object stored in a collection, used to
/// uniquely index results from multiple open collections at once.
impl Collection {
fn id_for_db_cache(&self) -> CollectionId {
CollectionId((&self.storage as *const _) as i64)
}
}
#[derive(Hash, PartialEq, Eq)]
struct CollectionId(i64);
#[derive(Deserialize)]
struct DBArgs {
sql: String,
args: Vec<crate::backend::dbproxy::SqlValue>,
}
pub trait Sizable {
/** Estimates the heap size of the value, in bytes */
fn estimate_size(&self) -> usize;
}
impl Sizable for Data {
fn estimate_size(&self) -> usize {
match self {
Data::StringValue(s) => s.len(),
Data::LongValue(_) => size_of::<i64>(),
Data::DoubleValue(_) => size_of::<f64>(),
Data::BlobValue(b) => b.len(),
}
}
}
impl Sizable for SqlValue {
fn estimate_size(&self) -> usize {
// Add a byte for the optional
self.data
.as_ref()
.map(|f| f.estimate_size() + 1)
.unwrap_or(1)
}
}
impl Sizable for Row {
fn estimate_size(&self) -> usize {
self.fields.iter().map(|x| x.estimate_size()).sum()
}
}
impl Sizable for DbResult {
fn estimate_size(&self) -> usize {
// Performance: It might be best to take the first x rows and determine the data types
// If we have floats or longs, they'll be a fixed size (excluding nulls) and should speed
// up the calculation as we'll only calculate a subset of the columns.
self.rows.iter().map(|x| x.estimate_size()).sum()
}
}
pub(crate) fn select_next_slice<'a>(rows: impl Iterator<Item = &'a Row>) -> Vec<Row> {
select_slice_of_size(rows, get_max_page_size())
.into_inner()
.1
}
fn select_slice_of_size<'a>(
mut rows: impl Iterator<Item = &'a Row>,
max_size: usize,
) -> FoldWhile<(usize, Vec<Row>)> {
let init: Vec<Row> = Vec::new();
rows.fold_while((0, init), |mut acc, x| {
let new_size = acc.0 + x.estimate_size();
// If the accumulator is 0, but we're over the size: return a single result so we don't loop forever.
// Theoretically, this shouldn't happen as data should be reasonably sized
if new_size > max_size && acc.0 > 0 {
Done(acc)
} else {
// PERF: should be faster to return (size, numElements) then bulk copy/slice
acc.1.push(x.to_owned());
Continue((new_size, acc.1))
}
})
}
type SequenceNumber = i32;
lazy_static! {
static ref HASHMAP: Mutex<HashMap<CollectionId, HashMap<SequenceNumber, DbResponse>>> =
Mutex::new(HashMap::new());
}
pub(crate) fn flush_single_result(col: &Collection, sequence_number: i32) {
HASHMAP
.lock()
.unwrap()
.get_mut(&col.id_for_db_cache())
.map(|storage| storage.remove(&sequence_number));
}
pub(crate) fn flush_collection(col: &Collection) {
HASHMAP.lock().unwrap().remove(&col.id_for_db_cache());
}
pub(crate) fn active_sequences(col: &Collection) -> Vec<i32> {
HASHMAP
.lock()
.unwrap()
.get(&col.id_for_db_cache())
.map(|h| h.keys().copied().collect())
.unwrap_or_default()
}
/**
Store the data in the cache if larger than than the page size.<br/>
Returns: The data capped to the page size
*/
pub(crate) fn trim_and_cache_remaining(
col: &Collection,
values: DbResult,
sequence_number: i32,
) -> DbResponse {
let start_index = 0;
// PERF: Could speed this up by not creating the vector and just calculating the count
let first_result = select_next_slice(values.rows.iter());
let row_count = values.rows.len() as i32;
if first_result.len() < values.rows.len() {
let to_store = DbResponse {
result: Some(values),
sequence_number,
row_count,
start_index,
};
insert_cache(col, to_store);
DbResponse {
result: Some(DbResult { rows: first_result }),
sequence_number,
row_count,
start_index,
}
} else {
DbResponse {
result: Some(values),
sequence_number,
row_count,
start_index,
}
}
}
fn insert_cache(col: &Collection, result: DbResponse) {
HASHMAP
.lock()
.unwrap()
.entry(col.id_for_db_cache())
.or_default()
.insert(result.sequence_number, result);
}
pub(crate) fn get_next(
col: &Collection,
sequence_number: i32,
start_index: i64,
) -> Option<DbResponse> {
let result = get_next_result(col, &sequence_number, start_index);
if let Some(resp) = result.as_ref() {
if resp.result.is_none() || resp.result.as_ref().unwrap().rows.is_empty() {
flush_single_result(col, sequence_number)
}
}
result
}
fn get_next_result(
col: &Collection,
sequence_number: &i32,
start_index: i64,
) -> Option<DbResponse> {
let map = HASHMAP.lock().unwrap();
let result_map = map.get(&col.id_for_db_cache())?;
let current_result = result_map.get(sequence_number)?;
// TODO: This shouldn't need to exist
let tmp: Vec<Row> = Vec::new();
let next_rows = current_result
.result
.as_ref()
.map(|x| x.rows.iter())
.unwrap_or_else(|| tmp.iter());
let skipped_rows = next_rows.clone().skip(start_index as usize).collect_vec();
println!("{}", skipped_rows.len());
let filtered_rows = select_next_slice(next_rows.skip(start_index as usize));
let result = DbResult {
rows: filtered_rows,
};
let trimmed_result = DbResponse {
result: Some(result),
sequence_number: current_result.sequence_number,
row_count: current_result.row_count,
start_index,
};
Some(trimmed_result)
}
static SEQUENCE_NUMBER: AtomicI32 = AtomicI32::new(0);
pub(crate) fn next_sequence_number() -> i32 {
SEQUENCE_NUMBER.fetch_add(1, Ordering::SeqCst)
}
lazy_static! {
// same as we get from io.requery.android.database.CursorWindow.sCursorWindowSize
static ref DB_COMMAND_PAGE_SIZE: Mutex<usize> = Mutex::new(1024 * 1024 * 2);
}
pub(crate) fn set_max_page_size(size: usize) {
let mut state = DB_COMMAND_PAGE_SIZE.lock().expect("Could not lock mutex");
*state = size;
}
fn get_max_page_size() -> usize {
*DB_COMMAND_PAGE_SIZE.lock().unwrap()
}
fn get_args(in_bytes: &[u8]) -> Result<DBArgs> {
let ret: DBArgs = serde_json::from_slice(in_bytes)?;
Ok(ret)
}
pub(crate) fn insert_for_id(col: &Collection, json: &[u8]) -> Result<i64> {
let req = get_args(json)?;
let args: Vec<_> = req.args.iter().map(|a| a as &dyn ToSql).collect();
col.storage.db.execute(&req.sql, &args[..])?;
Ok(col.storage.db.last_insert_rowid())
}
pub(crate) fn execute_for_row_count(col: &Collection, req: &[u8]) -> Result<i64> {
let req = get_args(req)?;
let args: Vec<_> = req.args.iter().map(|a| a as &dyn ToSql).collect();
let count = col.storage.db.execute(&req.sql, &args[..])?;
Ok(count as i64)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
backend::ankidroid::db::{select_slice_of_size, Sizable},
collection::open_test_collection,
pb::ankidroid::{sql_value, Row, SqlValue},
};
fn gen_data() -> Vec<SqlValue> {
vec![
SqlValue {
data: Some(sql_value::Data::DoubleValue(12.0)),
},
SqlValue {
data: Some(sql_value::Data::LongValue(12)),
},
SqlValue {
data: Some(sql_value::Data::StringValue(
"Hellooooooo World".to_string(),
)),
},
SqlValue {
data: Some(sql_value::Data::BlobValue(vec![])),
},
]
}
#[test]
fn test_size_estimate() {
let row = Row { fields: gen_data() };
let result = DbResult {
rows: vec![row.clone(), row],
};
let actual_size = result.estimate_size();
let expected_size = (17 + 8 + 8) * 2; // 1 variable string, 1 long, 1 float
let expected_overhead = 4 * 2; // 4 optional columns
assert_eq!(actual_size, expected_overhead + expected_size);
}
#[test]
fn test_stream_size() {
let row = Row { fields: gen_data() };
let result = DbResult {
rows: vec![row.clone(), row.clone(), row],
};
let limit = 74 + 1; // two rows are 74
let result = select_slice_of_size(result.rows.iter(), limit).into_inner();
assert_eq!(
2,
result.1.len(),
"The final element should not be included"
);
assert_eq!(
74, result.0,
"The size should be the size of the first two objects"
);
}
#[test]
fn test_stream_size_too_small() {
let row = Row { fields: gen_data() };
let result = DbResult { rows: vec![row] };
let limit = 1;
let result = select_slice_of_size(result.rows.iter(), limit).into_inner();
assert_eq!(
1,
result.1.len(),
"If the limit is too small, a result is still returned"
);
assert_eq!(
37, result.0,
"The size should be the size of the first objects"
);
}
const SEQUENCE_NUMBER: i32 = 1;
fn get(col: &Collection, index: i64) -> Option<DbResponse> {
get_next(col, SEQUENCE_NUMBER, index)
}
fn get_first(col: &Collection, result: DbResult) -> DbResponse {
trim_and_cache_remaining(col, result, SEQUENCE_NUMBER)
}
fn seq_number_used(col: &Collection) -> bool {
HASHMAP
.lock()
.unwrap()
.get(&col.id_for_db_cache())
.unwrap()
.contains_key(&SEQUENCE_NUMBER)
}
#[test]
fn integration_test() {
let col = open_test_collection();
let row = Row { fields: gen_data() };
// return one row at a time
set_max_page_size(row.estimate_size() - 1);
let db_query_result = DbResult {
rows: vec![row.clone(), row],
};
let first_jni_response = get_first(&col, db_query_result);
assert_eq!(
row_count(&first_jni_response),
1,
"The first call should only return one row"
);
let next_index = first_jni_response.start_index + row_count(&first_jni_response);
let second_response = get(&col, next_index);
assert!(
second_response.is_some(),
"The second response should return a value"
);
let valid_second_response = second_response.unwrap();
assert_eq!(row_count(&valid_second_response), 1);
let final_index = valid_second_response.start_index + row_count(&valid_second_response);
assert!(seq_number_used(&col), "The sequence number is assigned");
let final_response = get(&col, final_index);
assert!(
final_response.is_some(),
"The third call should return something with no rows"
);
assert_eq!(
row_count(&final_response.unwrap()),
0,
"The third call should return something with no rows"
);
assert!(
!seq_number_used(&col),
"Sequence number data has been cleared"
);
}
fn row_count(resp: &DbResponse) -> i64 {
resp.result.as_ref().map(|x| x.rows.len()).unwrap_or(0) as i64
}
}

View file

@ -0,0 +1,139 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use crate::{
error::{
DbError, DbErrorKind as DB, FilteredDeckError, InvalidInputError, NetworkError,
NetworkErrorKind as Net, NotFoundError, SearchErrorKind, SyncError, SyncErrorKind as Sync,
},
prelude::AnkiError,
};
pub(super) fn debug_produce_error(s: &str) -> AnkiError {
let info = "error_value".to_string();
match s {
"TemplateError" => AnkiError::TemplateError { info },
"DbErrorFileTooNew" => AnkiError::DbError {
source: DbError {
info,
kind: DB::FileTooNew,
},
},
"DbErrorFileTooOld" => AnkiError::DbError {
source: DbError {
info,
kind: DB::FileTooOld,
},
},
"DbErrorMissingEntity" => AnkiError::DbError {
source: DbError {
info,
kind: DB::MissingEntity,
},
},
"DbErrorCorrupt" => AnkiError::DbError {
source: DbError {
info,
kind: DB::Corrupt,
},
},
"DbErrorLocked" => AnkiError::DbError {
source: DbError {
info,
kind: DB::Locked,
},
},
"DbErrorOther" => AnkiError::DbError {
source: DbError {
info,
kind: DB::Other,
},
},
"NetworkError" => AnkiError::NetworkError {
source: NetworkError {
info,
kind: Net::Offline,
},
},
"SyncErrorConflict" => AnkiError::SyncError {
source: SyncError {
info,
kind: Sync::Conflict,
},
},
"SyncErrorServerError" => AnkiError::SyncError {
source: SyncError {
info,
kind: Sync::ServerError,
},
},
"SyncErrorClientTooOld" => AnkiError::SyncError {
source: SyncError {
info,
kind: Sync::ClientTooOld,
},
},
"SyncErrorAuthFailed" => AnkiError::SyncError {
source: SyncError {
info,
kind: Sync::AuthFailed,
},
},
"SyncErrorServerMessage" => AnkiError::SyncError {
source: SyncError {
info,
kind: Sync::ServerMessage,
},
},
"SyncErrorClockIncorrect" => AnkiError::SyncError {
source: SyncError {
info,
kind: Sync::ClockIncorrect,
},
},
"SyncErrorOther" => AnkiError::SyncError {
source: SyncError {
info,
kind: Sync::Other,
},
},
"SyncErrorResyncRequired" => AnkiError::SyncError {
source: SyncError {
info,
kind: Sync::ResyncRequired,
},
},
"SyncErrorDatabaseCheckRequired" => AnkiError::SyncError {
source: SyncError {
info,
kind: Sync::DatabaseCheckRequired,
},
},
"JSONError" => AnkiError::JsonError { info },
"ProtoError" => AnkiError::ProtoError { info },
"Interrupted" => AnkiError::Interrupted,
"CollectionNotOpen" => AnkiError::CollectionNotOpen,
"CollectionAlreadyOpen" => AnkiError::CollectionAlreadyOpen,
"NotFound" => AnkiError::NotFound {
source: NotFoundError {
type_name: "".to_string(),
identifier: "".to_string(),
backtrace: None,
},
},
"Existing" => AnkiError::Existing,
"FilteredDeckError" => AnkiError::FilteredDeckError {
source: FilteredDeckError::FilteredDeckRequired,
},
"SearchError" => AnkiError::SearchError {
source: SearchErrorKind::EmptyGroup,
},
_ => AnkiError::InvalidInput {
source: InvalidInputError {
message: info,
source: None,
backtrace: None,
},
},
}
}

View file

@ -0,0 +1,117 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
pub(crate) mod db;
pub(crate) mod error;
use self::{db::active_sequences, error::debug_produce_error};
use super::{
dbproxy::{db_command_bytes, db_command_proto},
Backend,
};
pub(super) use crate::pb::ankidroid::ankidroid_service::Service as AnkidroidService;
use crate::{
backend::ankidroid::db::{execute_for_row_count, insert_for_id},
pb::{
self as pb,
ankidroid::{DbResponse, GetActiveSequenceNumbersResponse, GetNextResultPageRequest},
generic::{self, Empty, Int32, Json},
},
prelude::*,
scheduler::timing::{self, fixed_offset_from_minutes},
};
impl AnkidroidService for Backend {
fn sched_timing_today_legacy(
&self,
input: pb::ankidroid::SchedTimingTodayLegacyRequest,
) -> Result<pb::scheduler::SchedTimingTodayResponse> {
let result = timing::sched_timing_today(
TimestampSecs::from(input.created_secs),
TimestampSecs::from(input.now_secs),
input.created_mins_west.map(fixed_offset_from_minutes),
fixed_offset_from_minutes(input.now_mins_west),
Some(input.rollover_hour as u8),
)?;
Ok(pb::scheduler::SchedTimingTodayResponse::from(result))
}
fn local_minutes_west_legacy(&self, input: pb::generic::Int64) -> Result<pb::generic::Int32> {
Ok(pb::generic::Int32 {
val: timing::local_minutes_west_for_stamp(input.val.into())?,
})
}
fn run_db_command(&self, input: Json) -> Result<Json> {
self.with_col(|col| db_command_bytes(col, &input.json))
.map(|json| Json { json })
}
fn run_db_command_proto(&self, input: Json) -> Result<DbResponse> {
self.with_col(|col| db_command_proto(col, &input.json))
}
fn run_db_command_for_row_count(&self, input: Json) -> Result<pb::generic::Int64> {
self.with_col(|col| execute_for_row_count(col, &input.json))
.map(|val| pb::generic::Int64 { val })
}
fn flush_all_queries(&self, _input: Empty) -> Result<Empty> {
self.with_col(|col| {
db::flush_collection(col);
Ok(Empty {})
})
}
fn flush_query(&self, input: Int32) -> Result<Empty> {
self.with_col(|col| {
db::flush_single_result(col, input.val);
Ok(Empty {})
})
}
fn get_next_result_page(&self, input: GetNextResultPageRequest) -> Result<DbResponse> {
self.with_col(|col| {
db::get_next(col, input.sequence, input.index).or_invalid("missing result page")
})
}
fn insert_for_id(&self, input: Json) -> Result<pb::generic::Int64> {
self.with_col(|col| insert_for_id(col, &input.json).map(Into::into))
}
fn set_page_size(&self, input: pb::generic::Int64) -> Result<Empty> {
// we don't require an open collection, but should avoid modifying this
// concurrently
let _guard = self.col.lock();
db::set_max_page_size(input.val as usize);
Ok(().into())
}
fn get_column_names_from_query(
&self,
input: generic::String,
) -> Result<pb::generic::StringList> {
self.with_col(|col| {
let stmt = col.storage.db.prepare(&input.val)?;
let names = stmt.column_names();
let names: Vec<_> = names.iter().map(ToString::to_string).collect();
Ok(names.into())
})
}
fn get_active_sequence_numbers(
&self,
_input: Empty,
) -> Result<GetActiveSequenceNumbersResponse> {
self.with_col(|col| {
Ok(GetActiveSequenceNumbersResponse {
numbers: active_sequences(col),
})
})
}
fn debug_produce_error(&self, input: generic::String) -> Result<Empty> {
Err(debug_produce_error(&input.val))
}
}

View file

@ -34,6 +34,7 @@ impl CollectionService for Backend {
let mut builder = CollectionBuilder::new(input.collection_path);
builder
.set_force_schema11(input.force_schema11)
.set_media_paths(input.media_folder_path, input.media_db_path)
.set_server(self.server)
.set_tr(self.tr.clone());

View file

@ -8,7 +8,14 @@ use rusqlite::{
};
use serde_derive::{Deserialize, Serialize};
use crate::{prelude::*, storage::SqliteStorage};
use crate::{
pb,
pb::ankidroid::{
sql_value::Data, DbResponse, DbResult as ProtoDbResult, Row, SqlValue as pb_SqlValue,
},
prelude::*,
storage::SqliteStorage,
};
#[derive(Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
@ -57,6 +64,42 @@ impl ToSql for SqlValue {
}
}
impl From<&SqlValue> for pb::ankidroid::SqlValue {
fn from(item: &SqlValue) -> Self {
match item {
SqlValue::Null => pb_SqlValue { data: Option::None },
SqlValue::String(s) => pb_SqlValue {
data: Some(Data::StringValue(s.to_string())),
},
SqlValue::Int(i) => pb_SqlValue {
data: Some(Data::LongValue(*i)),
},
SqlValue::Double(d) => pb_SqlValue {
data: Some(Data::DoubleValue(*d)),
},
SqlValue::Blob(b) => pb_SqlValue {
data: Some(Data::BlobValue(b.clone())),
},
}
}
}
impl From<&Vec<SqlValue>> for pb::ankidroid::Row {
fn from(item: &Vec<SqlValue>) -> Self {
Row {
fields: item.iter().map(pb::ankidroid::SqlValue::from).collect(),
}
}
}
impl From<&Vec<Vec<SqlValue>>> for pb::ankidroid::DbResult {
fn from(item: &Vec<Vec<SqlValue>>) -> Self {
ProtoDbResult {
rows: item.iter().map(Row::from).collect(),
}
}
}
impl FromSql for SqlValue {
fn column_result(value: ValueRef<'_>) -> std::result::Result<Self, FromSqlError> {
let val = match value {
@ -71,6 +114,10 @@ impl FromSql for SqlValue {
}
pub(super) fn db_command_bytes(col: &mut Collection, input: &[u8]) -> Result<Vec<u8>> {
serde_json::to_vec(&db_command_bytes_inner(col, input)?).map_err(Into::into)
}
pub(super) fn db_command_bytes_inner(col: &mut Collection, input: &[u8]) -> Result<DbResult> {
let req: DbRequest = serde_json::from_slice(input)?;
let resp = match req {
DbRequest::Query {
@ -107,7 +154,7 @@ pub(super) fn db_command_bytes(col: &mut Collection, input: &[u8]) -> Result<Vec
db_execute_many(&col.storage, &sql, &args)?
}
};
Ok(serde_json::to_vec(&resp)?)
Ok(resp)
}
fn update_state_after_modification(col: &mut Collection, sql: &str) {
@ -128,6 +175,20 @@ fn is_dql(sql: &str) -> bool {
head.starts_with("select")
}
pub(crate) fn db_command_proto(col: &mut Collection, input: &[u8]) -> Result<DbResponse> {
let result = db_command_bytes_inner(col, input)?;
let proto_resp = match result {
DbResult::None => ProtoDbResult { rows: Vec::new() },
DbResult::Rows(rows) => ProtoDbResult::from(&rows),
};
let trimmed = super::ankidroid::db::trim_and_cache_remaining(
col,
proto_resp,
super::ankidroid::db::next_sequence_number(),
);
Ok(trimmed)
}
pub(super) fn db_query_row(ctx: &SqliteStorage, sql: &str, args: &[SqlValue]) -> Result<DbResult> {
let mut stmt = ctx.db.prepare_cached(sql)?;
let columns = stmt.column_count();

View file

@ -5,6 +5,7 @@
#![allow(clippy::unnecessary_wraps)]
mod adding;
mod ankidroid;
mod card;
mod cardrendering;
mod collection;
@ -42,6 +43,7 @@ use tokio::runtime::{
};
use self::{
ankidroid::AnkidroidService,
card::CardsService,
cardrendering::CardRenderingService,
collection::CollectionService,
@ -120,6 +122,7 @@ impl Backend {
ServiceIndex::from_i32(service as i32)
.or_invalid("invalid service")
.and_then(|service| match service {
ServiceIndex::Ankidroid => AnkidroidService::run_method(self, method, input),
ServiceIndex::Scheduler => SchedulerService::run_method(self, method, input),
ServiceIndex::Decks => DecksService::run_method(self, method, input),
ServiceIndex::Notes => NotesService::run_method(self, method, input),

View file

@ -33,6 +33,8 @@ pub struct CollectionBuilder {
media_db: Option<PathBuf>,
server: Option<bool>,
tr: Option<I18n>,
// temporary option for AnkiDroid
force_schema11: Option<bool>,
}
impl CollectionBuilder {
@ -53,8 +55,8 @@ impl CollectionBuilder {
let server = self.server.unwrap_or_default();
let media_folder = self.media_folder.clone().unwrap_or_default();
let media_db = self.media_db.clone().unwrap_or_default();
let storage = SqliteStorage::open_or_create(&col_path, &tr, server)?;
let force_schema11 = self.force_schema11.unwrap_or_default();
let storage = SqliteStorage::open_or_create(&col_path, &tr, server, force_schema11)?;
let col = Collection {
storage,
col_path,
@ -88,6 +90,11 @@ impl CollectionBuilder {
self.tr = Some(tr);
self
}
pub fn set_force_schema11(&mut self, force: bool) -> &mut Self {
self.force_schema11 = Some(force);
self
}
}
#[cfg(test)]

View file

@ -123,14 +123,14 @@ impl ExchangeData {
}
fn check_ids(&self) -> Result<()> {
let now = TimestampMillis::now().0;
let tomorrow = TimestampMillis::now().adding_secs(86_400).0;
if self
.cards
.iter()
.map(|card| card.id.0)
.chain(self.notes.iter().map(|note| note.id.0))
.chain(self.revlog.iter().map(|entry| entry.id.0))
.any(|timestamp| timestamp > now)
.any(|timestamp| timestamp > tomorrow)
{
Err(AnkiError::InvalidId)
} else {

View file

@ -9,6 +9,7 @@ macro_rules! protobuf {
};
}
protobuf!(ankidroid, "ankidroid");
protobuf!(backend, "backend");
protobuf!(card_rendering, "card_rendering");
protobuf!(cards, "cards");

View file

@ -578,11 +578,11 @@ impl SqlWriter<'_> {
write!(
self.sql,
concat!(
"(SELECT min(id) > {cutoff} FROM revlog WHERE cid = c.id ",
"((SELECT coalesce(min(id) > {cutoff}, false) FROM revlog WHERE cid = c.id ",
// Exclude manual reschedulings
"AND ease != 0) ",
// Logically redundant, speeds up query
"AND c.id IN (SELECT cid FROM revlog WHERE id > {cutoff})"
"AND c.id IN (SELECT cid FROM revlog WHERE id > {cutoff}))"
),
cutoff = cutoff,
)
@ -785,8 +785,8 @@ mod test {
s(ctx, "introduced:3").0,
format!(
concat!(
"((SELECT min(id) > {cutoff} FROM revlog WHERE cid = c.id AND ease != 0) ",
"AND c.id IN (SELECT cid FROM revlog WHERE id > {cutoff}))"
"(((SELECT coalesce(min(id) > {cutoff}, false) FROM revlog WHERE cid = c.id AND ease != 0) ",
"AND c.id IN (SELECT cid FROM revlog WHERE id > {cutoff})))"
),
cutoff = (timing.next_day_at.0 - (86_400 * 3)) * 1_000,
)

View file

@ -11,7 +11,7 @@ impl GraphsContext {
let start_of_today_ms = self.next_day_start.adding_secs(-86_400).as_millis().0;
for review in self.revlog.iter().rev() {
if review.id.0 < start_of_today_ms {
break;
continue;
}
if review.review_kind == RevlogReviewKind::Manual {
continue;

View file

@ -762,7 +762,8 @@ mod test {
#[test]
fn add_card() {
let tr = I18n::template_only();
let storage = SqliteStorage::open_or_create(Path::new(":memory:"), &tr, false).unwrap();
let storage =
SqliteStorage::open_or_create(Path::new(":memory:"), &tr, false, false).unwrap();
let mut card = Card::default();
storage.add_card(&mut card).unwrap();
let id1 = card.id;

View file

@ -204,7 +204,12 @@ fn trace(s: &str) {
}
impl SqliteStorage {
pub(crate) fn open_or_create(path: &Path, tr: &I18n, server: bool) -> Result<Self> {
pub(crate) fn open_or_create(
path: &Path,
tr: &I18n,
server: bool,
force_schema11: bool,
) -> Result<Self> {
let db = open_or_create_collection_db(path)?;
let (create, ver) = schema_version(&db)?;
@ -249,6 +254,13 @@ impl SqliteStorage {
let storage = Self { db };
if force_schema11 {
if create || upgrade {
storage.commit_trx()?;
}
return storage_with_schema11(storage, ver);
}
if create || upgrade {
storage.upgrade_to_latest_schema(ver, server)?;
}
@ -369,3 +381,20 @@ impl SqliteStorage {
self.db.query_row(sql, [], |r| r.get(0)).map_err(Into::into)
}
}
fn storage_with_schema11(storage: SqliteStorage, ver: u8) -> Result<SqliteStorage> {
if ver != 11 {
if ver != SCHEMA_MAX_VERSION {
// partially upgraded; need to fully upgrade before downgrading
storage.begin_trx()?;
storage.upgrade_to_latest_schema(ver, false)?;
storage.commit_trx()?;
}
storage.downgrade_to(SchemaVersion::V11)?;
}
// Requery uses "TRUNCATE" by default if WAL is not enabled.
// We copy this behaviour here. See https://github.com/ankidroid/Anki-Android/pull/7977 for
// analysis. We may be able to enable WAL at a later time.
storage.db.pragma_update(None, "journal_mode", "TRUNCATE")?;
Ok(storage)
}

View file

@ -80,6 +80,10 @@ impl TimestampMillis {
pub fn as_secs(self) -> TimestampSecs {
TimestampSecs(self.0 / 1000)
}
pub fn adding_secs(self, secs: i64) -> Self {
Self(self.0 + secs * 1000)
}
}
fn elapsed() -> time::Duration {

View file

@ -32,6 +32,34 @@ $vars: (
),
),
),
transition: (
default: (
"Default duration of transitions in milliseconds",
(
default: 180ms,
),
),
medium: (
"Slightly longer transition duration in milliseconds",
(
default: 500ms,
),
),
slow: (
"Long transition duration in milliseconds",
(
default: 1000ms,
),
),
),
blur: (
default: (
"Default background blur value",
(
default: 20px,
)
)
)
),
colors: (
fg: (
@ -107,6 +135,13 @@ $vars: (
dark: palette(darkgray, 6),
),
),
glass: (
"Transparent background for surfaces containing text",
(
light: color.scale(white, $alpha: -60%),
dark: color.scale(palette(darkgray, 4), $alpha: -60%),
),
),
),
border: (
default: (

View file

@ -45,7 +45,7 @@ html {
button {
/* override transition for instant hover response */
transition: color 0.15s ease-in-out, box-shadow 0.15s ease-in-out !important;
transition: color var(--transition) ease-in-out, box-shadow var(--transition) ease-in-out !important;
border-radius: prop(border-radius);
@include button.base;
}

View file

@ -33,6 +33,6 @@ button {
@include elevation(1, $opacity-boost: -0.08);
&:hover {
@include elevation(2);
transition: box-shadow 0.2s linear;
transition: box-shadow var(--transition) linear;
}
}

View file

@ -11,7 +11,7 @@ body {
color: var(--fg);
background: var(--canvas);
margin: 1em;
transition: opacity 0.5s ease-out;
transition: opacity var(--transition-medium) ease-out;
overscroll-behavior: none;
}

12
tools/rebuild-web Executable file
View file

@ -0,0 +1,12 @@
#!/bin/bash
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
# Manually trigger a rebuild and reload of Anki's web stack
# NOTE: This script needs to be run from the project root
set -e
./ninja qt/aqt
./out/pyenv/bin/python tools/reload_webviews.py

55
tools/reload_webviews.py Executable file
View file

@ -0,0 +1,55 @@
#!/usr/bin/env python
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
"""
Trigger a reload of Anki's web views using QtWebEngine' Chromium
Remote Debugging interface.
"""
import argparse
import sys
import PyChromeDevTools # type: ignore[import]
DEFAULT_HOST = "localhost"
DEFAULT_PORT = 8080
def print_error(message: str):
print(f"Error: {message}", file=sys.stderr)
parser = argparse.ArgumentParser("reload_webviews")
parser.add_argument(
"--host",
help=f"Host via which the Chrome session can be reached, e.g. {DEFAULT_HOST}",
type=str,
default=DEFAULT_HOST,
required=False,
)
parser.add_argument(
"--port",
help=f"Port via which the Chrome session can be reached, e.g. {DEFAULT_PORT}",
type=str,
default=DEFAULT_PORT,
required=False,
)
args = parser.parse_args()
try:
chrome = PyChromeDevTools.ChromeInterface(host=args.host, port=args.port)
except Exception as e:
print_error(
f"Could not establish connection to Chromium remote debugger. Exception:\n{e}"
)
exit(1)
if chrome.tabs is None:
print_error("Was unable to get active web views.")
exit(1)
for tab_index, tab_data in enumerate(chrome.tabs):
print(f"Reloading page: {tab_data['title']}")
chrome.connect(tab=tab_index, update_tabs=False)
chrome.Page.reload()

View file

@ -1,13 +0,0 @@
#!/bin/bash
#
# Monitor the ts folder and rebuild aqt's data each time
# it changes, for testing pages locally.
#
# On a Mac, useful to combine with ts-run.
# run once at startup
cmd='printf \\033c\\n; bazel build qt:runanki'
sh -c "$cmd"
# then monitor for changes
fswatch -r -o ts | xargs -n1 -I{} sh -c "$cmd"

23
tools/web-watch Executable file
View file

@ -0,0 +1,23 @@
#!/bin/bash
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
# Monitor all web-related folders and rebuild and reload Anki's web stack
# when a change is detected.
set -e
MONITORED_FOLDERS=("ts/" "sass/" "qt/aqt/data/web/")
MONITORED_EVENTS=("Created" "Updated" "Removed")
on_change_detected="printf \\033c\\n; \"./tools/rebuild-web\""
event_args=""
for event in "${MONITORED_EVENTS[@]}"; do
event_args+="--event ${event} "
done
# poll_monitor comes with a slight performance penalty, but seems to more
# reliably identify file system events across both macOS and Linux
fswatch -r -o -m poll_monitor ${event_args[@]} \
"${MONITORED_FOLDERS[@]}" | xargs -n1 -I{} sh -c "$on_change_detected"

View file

@ -75,7 +75,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
z-index: 4;
height: var(--client-height);
box-shadow: var(--box-shadow);
transition: box-shadow 0.1s ease-in-out;
transition: box-shadow var(--transition) ease-in-out;
}
}
</style>

View file

@ -63,7 +63,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
@include elevation(4);
}
}
transition: box-shadow 0.2s ease-in-out;
transition: box-shadow var(--transition) ease-in-out;
page-break-inside: avoid;
}
h1 {
border-bottom: 1px solid var(--border);
@ -72,7 +73,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
right: 0;
bottom: 4px;
color: var(--fg-faint);
transition: color 0.2s linear;
transition: color var(--transition) linear;
&:hover {
transition: none;
color: var(--fg);

View file

@ -1,7 +1,7 @@
@import "sass/base";
// override Bootstrap transition duration
$carousel-transition: 0.2s;
$carousel-transition: var(--transition);
@import "bootstrap/scss/buttons";
@import "bootstrap/scss/button-group";

View file

@ -4,9 +4,12 @@
import { getSelection, isSelectionCollapsed } from "@tslib/cross-browser";
import { elementIsEmpty, nodeIsElement, nodeIsText } from "@tslib/dom";
import { on } from "@tslib/events";
import type { Unsubscriber } from "svelte/store";
import { get } from "svelte/store";
import { moveChildOutOfElement } from "../domlib/move-nodes";
import { placeCaretAfter } from "../domlib/place-caret";
import { isComposing } from "../sveltelib/composition";
import type { FrameElement } from "./frame-element";
/**
@ -53,7 +56,6 @@ function restoreHandleContent(mutations: MutationRecord[]): void {
}
const handleElement = target;
const placement = handleElement instanceof FrameStart ? "beforebegin" : "afterend";
const frameElement = handleElement.parentElement as FrameElement;
for (const node of mutation.addedNodes) {
@ -75,7 +77,7 @@ function restoreHandleContent(mutations: MutationRecord[]): void {
referenceNode = moveChildOutOfElement(
frameElement,
node,
placement,
handleElement.placement,
);
}
}
@ -84,25 +86,16 @@ function restoreHandleContent(mutations: MutationRecord[]): void {
!nodeIsText(target)
|| !isFrameHandle(target.parentElement)
|| skippableNode(target.parentElement, target)
|| target.parentElement.unsubscribe
) {
continue;
}
const handleElement = target.parentElement;
const placement = handleElement instanceof FrameStart ? "beforebegin" : "afterend";
const frameElement = handleElement.parentElement! as FrameElement;
const cleaned = target.data.replace(spaceRegex, "");
const text = new Text(cleaned);
if (placement === "beforebegin") {
frameElement.before(text);
} else {
frameElement.after(text);
if (get(isComposing)) {
target.parentElement.subscribeToCompositionEvent();
continue;
}
handleElement.refreshSpace();
referenceNode = text;
referenceNode = target.parentElement.moveTextOutOfFrame(target.data);
}
}
@ -114,6 +107,8 @@ function restoreHandleContent(mutations: MutationRecord[]): void {
const handleObserver = new MutationObserver(restoreHandleContent);
const handles: Set<FrameHandle> = new Set();
type Placement = Extract<InsertPosition, "beforebegin" | "afterend">;
export abstract class FrameHandle extends HTMLElement {
static get observedAttributes(): string[] {
return ["data-frames"];
@ -128,6 +123,8 @@ export abstract class FrameHandle extends HTMLElement {
*/
partiallySelected = false;
frames?: string;
abstract placement: Placement;
unsubscribe: Unsubscriber | null;
constructor() {
super();
@ -136,6 +133,7 @@ export abstract class FrameHandle extends HTMLElement {
subtree: true,
characterData: true,
});
this.unsubscribe = null;
}
attributeChangedCallback(name: string, old: string, newValue: string): void {
@ -197,13 +195,56 @@ export abstract class FrameHandle extends HTMLElement {
this.removeMoveIn?.();
this.removeMoveIn = undefined;
this.unsubscribeToCompositionEvent();
}
abstract notifyMoveIn(offset: number): void;
moveTextOutOfFrame(data: string): Text {
const frameElement = this.parentElement! as FrameElement;
const cleaned = data.replace(spaceRegex, "");
const text = new Text(cleaned);
if (this.placement === "beforebegin") {
frameElement.before(text);
} else if (this.placement === "afterend") {
frameElement.after(text);
}
this.refreshSpace();
return text;
}
/**
* https://github.com/ankitects/anki/issues/2251
*
* Work around the issue by not moving the input string while an IME session
* is active, and moving the final output from IME only after the session ends.
*/
subscribeToCompositionEvent(): void {
this.unsubscribe = isComposing.subscribe((composing) => {
if (!composing) {
if (this.firstChild && nodeIsText(this.firstChild)) {
placeCaretAfter(this.moveTextOutOfFrame(this.firstChild.data));
}
this.unsubscribeToCompositionEvent();
}
});
}
unsubscribeToCompositionEvent(): void {
this.unsubscribe?.();
this.unsubscribe = null;
}
}
export class FrameStart extends FrameHandle {
static tagName = "frame-start";
placement: Placement;
constructor() {
super();
this.placement = "beforebegin";
}
getFrameRange(): Range {
const range = new Range();
@ -245,6 +286,12 @@ export class FrameStart extends FrameHandle {
export class FrameEnd extends FrameHandle {
static tagName = "frame-end";
placement: Placement;
constructor() {
super();
this.placement = "afterend";
}
getFrameRange(): Range {
const range = new Range();

View file

@ -16,6 +16,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { context as noteEditorContext } from "./NoteEditor.svelte";
import { editingInputIsRichText } from "./rich-text-input";
export let alwaysEnabled = false;
const { focusedInput, fields } = noteEditorContext.get();
// Workaround for Cmd+Option+Shift+C not working on macOS. The keyup approach works
@ -67,7 +69,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
});
}
$: disabled = !$focusedInput || !editingInputIsRichText($focusedInput);
$: disabled =
!alwaysEnabled && (!$focusedInput || !editingInputIsRichText($focusedInput));
const incrementKeyCombination = "Control+Shift+C";
const sameKeyCombination = "Control+Alt+Shift+C";

View file

@ -18,7 +18,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
.collapse-badge {
display: inline-block;
opacity: 0.4;
transition: opacity 0.2s ease-in-out, transform 80ms ease-in;
transition: opacity var(--transition) ease-in-out,
transform var(--transition) ease-in;
&.highlighted {
opacity: 1;
}

View file

@ -38,7 +38,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</IconButton>
</ButtonGroup>
<ClozeButtons on:surround />
<ClozeButtons on:surround alwaysEnabled={true} />
<ButtonGroup>
<IconButton

View file

@ -11,6 +11,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type CodeMirrorLib from "codemirror";
import { tick } from "svelte";
import { writable } from "svelte/store";
import { isComposing } from "sveltelib/composition";
import Popover from "../../components/Popover.svelte";
import Shortcut from "../../components/Shortcut.svelte";
@ -79,6 +80,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const code = writable("");
function showOverlay(image: HTMLImageElement, pos?: CodeMirrorLib.Position) {
if ($isComposing) {
// Should be canceled while an IME composition session is active
return;
}
const [promise, allowResolve] = promiseWithResolver<void>();
allowPromise = promise;

View file

@ -87,7 +87,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<style lang="scss">
svg {
transition: opacity 1s;
transition: opacity var(--transition-slow);
}
.counts-outer {

View file

@ -21,8 +21,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<style lang="scss">
@use "sass/elevation" as *;
.graph {
page-break-inside: avoid;
/* See graph-styles.ts for constants referencing global styles */
:global(.graph-element-clickable) {
cursor: pointer;

View file

@ -78,6 +78,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
@media only screen and (max-width: 600px) {
font-size: 12px;
}
@media only print {
// grid layout does not honor page-break-inside
display: block;
margin-top: 3em;
}
}
.spacer {

View file

@ -148,7 +148,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
&.loading {
opacity: 0.5;
transition: opacity 1s;
transition: opacity var(--transition-slow);
}
}

View file

@ -50,7 +50,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
font-size: 15px;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
transition: opacity var(--transition);
color: var(--fg);
background: var(--canvas-overlay);

View file

@ -75,14 +75,15 @@ export function buildHistogram(
.thresholds(scale.ticks(desiredBars))(data.daysAdded.entries() as any);
// empty graph?
if (!sum(bins, (bin) => bin.length)) {
const accessor = getNumericMapBinValue as any;
if (!sum(bins, accessor)) {
return [null, []];
}
const adjustedRange = scaleLinear().range([0.7, 0.3]);
const colourScale = scaleSequential((n) => interpolateBlues(adjustedRange(n)!)).domain([xMax!, xMin!]);
const totalInPeriod = sum(bins, (bin) => bin.length);
const totalInPeriod = sum(bins, accessor);
const periodDays = Math.abs(xMin!);
const cardsPerDay = Math.round(totalInPeriod / periodDays);
const tableData = [
@ -102,7 +103,7 @@ export function buildHistogram(
_percent: number,
): string {
const day = dayLabel(bin.x0!, bin.x1!);
const cards = tr.statisticsCards({ cards: bin.length });
const cards = tr.statisticsCards({ cards: accessor(bin) });
const total = tr.statisticsRunningTotal();
const totalCards = tr.statisticsCards({ cards: cumulative });
return `${day}:<br>${cards}<br>${total}: ${totalCards}`;

View file

@ -145,6 +145,9 @@ export async function _updateQA(
await _runHook(onUpdateHook);
// dynamic toolbar background
bridgeCommand("updateToolbar");
// wait for mathjax to ready
await MathJax.startup.promise
.then(() => {

View file

@ -10,7 +10,10 @@ hr {
body {
margin: 20px;
overflow-wrap: break-word;
background-color: var(--canvas);
// default background setting to fit with toolbar
background-size: 100vw;
background-repeat: no-repeat;
background-attachment: fixed;
}
// explicit nightMode definition required

View file

@ -0,0 +1,12 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { writable } from "svelte/store";
/**
* Indicates whether an IME composition session is currently active
*/
export const isComposing = writable(false);
window.addEventListener("compositionstart", () => isComposing.set(true));
window.addEventListener("compositionend", () => isComposing.set(false));

View file

@ -3791,9 +3791,9 @@ json-stable-stringify-without-jsonify@^1.0.1:
integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=
json5@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe"
integrity sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==
version "1.0.2"
resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593"
integrity sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==
dependencies:
minimist "^1.2.0"