Compare commits

...
21 Commits
Author SHA1 Message Date
haraldandClaude Opus 5.5 19fc8dd3ca ci: fetch the APK after restoring the Rust cache
Build & Publish Release / Android APK (push) Successful in 7m18s
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 13m41s
A cache restored without an exact match (Cargo.lock changed with the
rustls update) cleans target/, which removed the downloaded APK, so the
v0.42.0 release job stopped at INK_NOTES_REQUIRE_APK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:59:44 +01:00
haraldandClaude Opus 5.5 17d0f07b9a build(deps): bump rustls from 0.23.45 to 0.23.46 (Issue #51)
Build & Publish Release / Android APK (push) Successful in 8m14s
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 25m48s
Fixes RUSTSEC key material in the Debug output of client session values;
ink-notes doesn't use a custom session store, so it wasn't affected.
Flatpak cargo sources updated alongside.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:23:33 +01:00
haraldandClaude Opus 5.5 200f76e32f chore(release): bump version to 0.42.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:12:58 +01:00
haraldandClaude Opus 5.5 b8748c56d4 feat(sync): built-in Nextcloud sync (Issue #49, stage 2)
Sign in to any Nextcloud server (also private ones in a subfolder, with
their own port or a self-signed certificate) through the browser with
Login Flow v2; the app gets an app password of its own, which
"Disconnect" revokes again.

A background thread syncs the storage folder with the folder ink-notes
on the server every 30 seconds and shortly after saving, three-way
against the state after the last pass: one-sided changes are copied,
manifests and library.json are merged, a page changed on both sides is
kept twice (the server's version as conflict copy, which disk_watch
turns into a page), empty pages give way, deletions go into the trash
on both sides and lose against changes. Changes to the open notebook
wait while it has unsaved changes. Folder ETags spare listing unchanged
notebooks; the pass stops instead of deleting when the storage folder
or the server folder is missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:24:49 +01:00
haraldandClaude Opus 5.5 8c0ccdd394 chore(release): bump version to 0.41.1
Build & Publish Release / Android APK (push) Successful in 6m33s
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 12m52s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 16:48:55 +01:00
haraldandClaude Opus 5.5 3d6dbf09da fix(android): keep the toolbar out of the status and navigation bars (Issue #50)
NativeActivity draws the whole window, also below the system bars, so the
top toolbar button lay under the status bar and taps there went to
Android (no other notebook could be opened). InkActivity now reports the
system bar and cutout insets; the app shrinks egui's screen rect to the
free area and adds the side insets to the page centering. The page itself
still scrolls below the bars.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:32:43 +01:00
haraldandClaude Opus 5.5 2a9a67d399 chore(release): bump version to 0.41.0
Build & Publish Release / Android APK (push) Successful in 6m23s
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 13m57s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:48:54 +01:00
haraldandClaude Opus 5.5 307c803246 feat(math): offer the result next to a handwritten = (Issue #46, stage 3)
New module math_assist.rs: when the last two strokes form an = and the pen
rests for 500 ms, the strokes left of it (in a band around the =, up to a
wide gap) are recognized and computed. The suggestion appears next to
the = and shows what was read, e.g. "2⁴ = 16"; tapping it with the pen
or clicking inserts the result as a text box in the ink color and as high
as the handwriting, as one undo step. The chip is not interactive for
egui, so fingers and palms pass through. Any new stroke drops it. In a
chain like x = 2 · 3 = the part after the previous = is computed.

Off by default: ☰ → Input → "Compute formulas after =", saved in
ui.json. math::display writes the recognized expression neatly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:45:00 +01:00
haraldandClaude Opus 5.5 d16cd2db72 feat(math): recognize handwritten expressions (Issue #46, stage 2)
New module math_ink.rs turns the strokes of an expression into text for
math::solve. Consecutive overlapping strokes are grouped into symbols,
each symbol is classified with the $P point-cloud recognizer (plus the
turning angle and a small stroke-count penalty) against built-in
templates for digits, + ( ) / x y a b, and the structure comes from the
positions: stacked lines are =, stacked dots :, lines with symbols above
and below are fraction bars, low dots between digits decimal commas and
raised symbols exponents. Own templates can be added for stage 4.

Tested with synthetic handwriting (slanted, rotated, warped); not used by
the UI yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:27:25 +01:00
haraldandClaude Opus 5.5 cd490dd9a7 feat(math): exact evaluation of handwritten expressions (Issue #46, stage 1)
New module math.rs: solve() reads a recognised expression such as 2^4,
2,5 · 3 or (x + 3)² and returns its result as text (16, 7,5,
x² + 6x + 9). Own parser with exact fractions and polynomial expansion
instead of evalexpr, as implicit multiplication and expanding need it
anyway. Not used by the UI yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:34:32 +01:00
haraldandClaude Opus 5.5 8af10fe542 chore(release): bump version to 0.40.0
Build & Publish Release / Android APK (push) Successful in 6m46s
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 12m5s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:32:30 +01:00
haraldandClaude Opus 5.5 6694d79f16 ci(android): ship the APK with every release (Issue #45)
The release job now waits for the android job, takes its APK, lists it in
SHA256SUMS.txt and uploads it as a release asset. The android job is no
longer optional. README describes installing the APK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:32:30 +01:00
haraldandClaude Opus 5.5 ad1dab9161 chore: ignore Android keystores
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:27:52 +01:00
harald 8c436fb28c docs: update banners with Windows, Android and new import features 2026-10-27 21:45:29 +01:00
haraldandClaude Opus 5.5 7584827c04 ci(android): containerized APK build and CI job (Issue #45)
- scripts/setup-android-sdk.sh installs fixed versions of the Android
  command-line tools (checksum verified), platform 34, build-tools
  34.0.0 and NDK 28.2
- packaging/android/Containerfile + scripts/build-apk-container.sh build
  the APK in a container (podman or docker) with JDK 17 and Rust 1.99
- New CI job "Android APK (experimental)": builds the APK and keeps it
  as a workflow artifact, signed with the key from the secrets
  ANDROID_KEYSTORE_B64/ANDROID_KEYSTORE_PASS/ANDROID_KEY_ALIAS if set.
  Not part of the release yet; its failure doesn't block the release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 21:39:35 +01:00
haraldandClaude Opus 5.5 0be321837c feat(android): Java activity for keyboard text and the system file dialog (Issue #42)
InkActivity extends NativeActivity:
- An invisible view with its own input connection receives the soft
  keyboard's text (commitText, deleteSurroundingText), so characters
  without a key event (umlauts by long press, ...) arrive as well. The
  field is a visible-password field, so keyboards commit each character
  instead of composing words.
- Import and "insert image" open the system file dialog
  (ACTION_OPEN_DOCUMENT); the chosen file is copied to the cache folder
  and handed to Rust by path.

src/android_bridge.rs calls the activity through JNI and receives its
native calls; package-android.sh compiles the class with javac and d8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 21:23:52 +01:00
haraldandClaude Opus 5.5 a8aef30db7 feat(android): pen input, soft keyboard and exports to Downloads (Issues #42, #44)
- Pen vs. finger (#44): the patched winit hands Android pointers with
  tool type stylus/eraser to the app separately (take_pen_samples, with
  hover and button state) instead of as touch events. src/pen.rs turns
  them, like octotablet's events on the desktop, into one PenInput
  stream, so pen buttons, the eraser end and palm rejection work the
  same everywhere. Hover exit right before touch-down does not count as
  leaving; a pen without hover leaves 150 ms after lifting.
- Soft keyboard: shown via AndroidApp::show_soft_input when a text field
  gets focus, hidden when it loses it. The patched winit now fills
  KeyEvent::text, so typed characters reach egui.
- Exports (PDF, PNG, SVG) are written to the app folder and copied to
  Download/ink-notes through the MediaStore (Android 10+, via JNI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:54:48 +01:00
haraldandClaude Opus 5.5 4ca21483e5 fix(sync): compare small files by content to detect quick rewrites
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 27m40s
File times are only as fine as the kernel clock tick. A manifest rewritten
right after a check with the same length (one page replaced by another)
looked unchanged, so the change was never picked up. Files up to 64 KiB
(manifest, open marker, small pages) now also carry a content hash.

Showed up as a flaky test_page_still_being_synced_is_tried_again in CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:23:20 +01:00
haraldandClaude Opus 5.5 c95cde8ec8 chore(release): bump version to 0.39.3
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 3m32s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:16:49 +01:00
haraldandClaude Opus 5.5 6b80cd672a fix: stop redrawing endlessly while idle (battery drain)
egui-winit reports `repaint: true` for every RedrawRequested, and the app
followed that with another request_redraw – so it redrew at full frame
rate all the time, keeping the app and the compositor (gnome-shell) busy.
Follow-up frames egui needs already come via `repaint_delay`.

The toast no longer redraws every frame either: one wake-up when its
fade-out starts is enough. While the updater checks or downloads, the
loop looks for its results every 100 ms instead.

Idle CPU under Xvfb/lavapipe: 187 % before, 0 % after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:14:46 +01:00
haraldandClaude Opus 5.5 7faf78ddeb feat(android): follow the activity lifecycle and build an APK (Issue #42)
- The drawing surface is created once the native window exists (Resumed)
  and released when the app goes to the background (Suspended); before
  that everything is saved, as Android may stop the app at any time
- Android's back button acts like Escape instead of closing the app
- Panics are written to logcat; no update check on Android yet
- scripts/package-android.sh builds target/ink-notes_<version>_arm64.apk
  without Gradle (NDK, aapt2, zipalign, apksigner); signing key from
  ANDROID_KEYSTORE/ANDROID_KEYSTORE_PASS/ANDROID_KEY_ALIAS, otherwise
  the debug key

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:23:28 +01:00
41 changed files with 9470 additions and 855 deletions

No files matched your search

+67 -1
View File
@@ -13,6 +13,8 @@ jobs:
# Standardmäßig 'ubuntu-latest'. Falls Ihr Runner unter einem anderen Label registriert ist
# (z. B. 'linux', 'ubuntu-24.04' oder 'host'), passen Sie dieses Label an.
runs-on: ubuntu-latest
# The APK from the android job goes into the release and its SHA256SUMS.txt
needs: android
steps:
- name: Checkout Repository
uses: actions/checkout@v4.2.2
@@ -63,13 +65,22 @@ jobs:
cargo clippy --all-targets -- -D warnings
cargo test
- name: Build Release Artifacts (.deb, .AppImage, .exe, SHA256SUMS.txt + .minisig)
# Only after the Rust cache step: restoring a cache that doesn't match exactly (e.g.
# after Cargo.lock changed) cleans target/ and would remove the APK again
- name: Fetch Android APK
uses: actions/download-artifact@v4.3.0
with:
name: ink-notes-android-apk
path: target
- name: Build Release Artifacts (.deb, .AppImage, .exe, .apk, SHA256SUMS.txt + .minisig)
run: |
chmod +x scripts/*.sh
./scripts/build-release.sh
env:
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
INK_NOTES_REQUIRE_SIGNATURE: "1"
INK_NOTES_REQUIRE_APK: "1"
- name: Publish Release to Gitea
run: |
@@ -79,3 +90,58 @@ jobs:
GITEA_SERVER: ${{ github.server_url }}
REPO_OWNER: ${{ github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
android:
# Builds the signed APK (Issue #45) and hands it to the release job as a workflow artifact
name: Android APK
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4.2.2
- name: Install JDK and Tools
run: |
export DEBIAN_FRONTEND=noninteractive
SUDO=""
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
fi
$SUDO apt-get update -y
$SUDO apt-get install -y --no-install-recommends \
openjdk-17-jdk-headless build-essential pkg-config curl unzip zip
- name: Setup Rust Toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
with:
toolchain: stable
target: aarch64-linux-android
cache: true
rustflags: ""
- name: Cache Android SDK
uses: actions/cache@v4.2.3
with:
path: ~/android-sdk
key: android-sdk-${{ hashFiles('scripts/setup-android-sdk.sh') }}
- name: Install Android SDK and NDK
run: ./scripts/setup-android-sdk.sh
- name: Build APK
run: |
if [ -n "${ANDROID_KEYSTORE_B64}" ]; then
KEYSTORE="${RUNNER_TEMP:-/tmp}/release.keystore"
printf '%s' "${ANDROID_KEYSTORE_B64}" | base64 -d > "${KEYSTORE}"
export ANDROID_KEYSTORE="${KEYSTORE}"
fi
./scripts/package-android.sh
env:
ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
ANDROID_KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASS }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
- name: Hand APK to the Release Job
uses: actions/upload-artifact@v4.6.2
with:
name: ink-notes-android-apk
path: target/ink-notes_*_arm64.apk
+4
View File
@@ -1,3 +1,7 @@
/target
*.pdf
export.pdf
# Android release signing key, never commit it
*.keystore
*.jks
Generated
+6 -3
View File
@@ -1540,7 +1540,7 @@ dependencies = [
[[package]]
name = "ink-notes"
version = "0.39.2"
version = "0.42.0"
dependencies = [
"ab_glyph",
"android_logger",
@@ -1556,6 +1556,7 @@ dependencies = [
"hayro",
"hayro-svg",
"image",
"jni",
"log",
"lopdf",
"minisign-verify",
@@ -1564,6 +1565,7 @@ dependencies = [
"pollster",
"quick-xml",
"resvg",
"rustls",
"semver",
"serde",
"serde_json",
@@ -1575,6 +1577,7 @@ dependencies = [
"wayland-client",
"wayland-protocols",
"webbrowser",
"webpki-roots 0.26.11",
"wgpu",
"winit",
]
@@ -2577,9 +2580,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.45"
version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"log",
"once_cell",
+8 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "ink-notes"
version = "0.39.2"
version = "0.42.0"
edition = "2021"
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes"
@@ -38,6 +38,10 @@ semver = "1.0"
sha2 = "0.10"
ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] }
url = "2.5"
# Nextcloud sync (Issue #49): pinning a self-signed certificate needs our own verifier;
# same versions and features as ureq uses
rustls = { version = "0.23", default-features = false, features = ["logging", "ring", "std", "tls12"] }
webpki-roots = "0.26"
# Bilder einfügen: Dekodieren (PNG/JPEG/GIF/WebP/BMP) und Systemzwischenablage
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] }
@@ -69,10 +73,12 @@ wayland-backend = { version = "0.3", features = ["client_system"] }
wayland-protocols = { version = "0.31", features = ["client", "unstable"] }
# Android (Issue #42): winit bound to the native activity. The library is built as
# cdylib with `cargo rustc --lib --crate-type cdylib --target aarch64-linux-android`
# cdylib and packed into an APK by scripts/package-android.sh
[target.'cfg(target_os = "android")'.dependencies]
winit = { version = "0.29", features = ["rwh_06", "android-native-activity"] }
android_logger = { version = "0.13", default-features = false }
# Saving exports to Downloads through Android's MediaStore
jni = { version = "0.21", default-features = false }
log = "0.4"
[package.metadata.android]
+16 -3
View File
@@ -13,7 +13,7 @@
ink-notes speichert Handschrift als Vektoren, erkennt auf Wunsch Formen, kann PDFs zum Beschriften sowie Notizbücher aus Xournal++, Rnote und Saber importieren, Bilder und Textfelder einfügen und alles wieder als PDF, PNG oder SVG exportieren. Gespeichert wird automatisch im Hintergrund.
Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11). Eine Windows-Version gibt es als experimentelles portables Programm.
Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11). Eine Windows-Version gibt es als experimentelles portables Programm, eine Android-Version als APK.
Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemsprache, umstellen lässt sie sich im ☰-Menü unter „Sprache / Language“.
@@ -37,6 +37,11 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
- Erkannt werden Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Vielecke, Koordinatensysteme (L-Form), Achsenkreuze (ein „+“ in einem Zug oder zwei gekreuzte Geraden, Pfeilspitzen an allen vier Enden) und Oktaeder (Skizze als 3D-Körper).
- Die Erkennung arbeitet rein geometrisch (Bogenlänge, Douglas-Peucker, Kreis-Fit), ohne Machine Learning. Nicht alles wird erkannt – was nicht passt, bleibt Handschrift.
### Formeln ausrechnen (experimentell)
- Einschalten im ☰-Menü unter Eingabe („Formeln nach „=“ ausrechnen“). Nach einem handgeschriebenen `=` und einer kurzen Pause erscheint daneben das Ergebnis samt dem Gelesenen, z. B. `2⁴ = 16` oder `(x + 3)² = x² + 6x + 9`; Antippen mit dem Stift oder Klicken fügt es als Textfeld in der Stiftfarbe und in der Höhe der Handschrift ein (Rückgängig entfernt es). Finger und Handballen lösen nichts aus.
- Gelesen werden Ziffern, `+ − · : /`, Klammern, Hochzahlen (hochgestellt geschrieben), Bruchstriche, Dezimalkommas und die Variablen `x`, `y`, `a`, `b`. Zahlen werden exakt als Brüche gerechnet, Ausdrücke mit Variablen ausmultipliziert. Vor der Formel etwas Abstand lassen, damit weiter links Geschriebenes nicht mitgelesen wird; in einer Kette wie `x = 2 · 3 =` wird der Teil nach dem vorigen `=` gerechnet.
- Die Zeichen werden mit Punktwolken-Vorlagen ($P) erkannt, ohne Machine Learning und offline. Zeigt der Vorschlag ein falsches Zeichen, ihn einfach ignorieren.
### Auswählen und Bearbeiten
- **Auswahlwerkzeug** (`V`): antippen oder Rahmen aufziehen, dann verschieben, an den Ecken skalieren, am Griff drehen oder mit `R` um 90° drehen. Funktioniert mit Maus, Finger und Stift.
- Auswahl über die Palette **umfärben**, **kopieren/einfügen** (`Strg + C/V`), **löschen** (`Entf`).
@@ -69,6 +74,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
- **Beim Schließen** (Fenster oder `Strg + Q`) und beim Wechsel des Notizbuchs wird alles Ausstehende geschrieben, bevor es weitergeht. Schlägt das fehl, bleibt die App offen und bietet Wiederholen oder eine Notfall-Sicherung an.
- `Strg + S` speichert sofort. Der Punkt am ☰-Knopf zeigt den Stand: grün = gespeichert, orange = Änderungen warten, gelb = speichert gerade, rot = Fehler.
- **Speicherort** (☰-Menü, „Speicherort“): Liegen die Notizbücher in einem Ordner, den **Nextcloud, Google Drive, Syncthing oder Dropbox** abgleichen, sind sie auf allen Geräten verfügbar. Die Notizbücher liegen im Unterordner `ink-notes`; ist er noch leer, werden die vorhandenen Notizbücher dorthin kopiert (die alten bleiben erhalten). Änderungen von einem anderen Gerät erscheinen nach wenigen Sekunden ohne neu zu öffnen – aber nur, solange lokal alles gespeichert ist, damit nichts überschrieben wird. Legt der Sync-Client eine Konfliktkopie einer Seite an, wird sie als eigene Seite („Konfliktkopie: …“) übernommen statt verloren zu gehen. Ist ein Notizbuch gerade auf einem anderen Gerät geöffnet, warnt die App beim Öffnen.
- **Nextcloud eingebaut** (☰-Menü, „Nextcloud“; auch unter Android und ohne Sync-Client): Adresse der eigenen Nextcloud eingeben – auch ein privater Server, in einem Unterordner (`example.org/nextcloud`) oder mit eigenem Port. Die Anmeldung läuft im Browser (auch mit Zwei-Faktor-Anmeldung); die App bekommt ein eigenes App-Passwort, das sich in Nextcloud unter Einstellungen → Sicherheit jederzeit widerrufen lässt, und sieht das echte Passwort nie. Die Notizbücher werden alle 30 Sekunden und kurz nach dem Speichern mit dem Ordner `ink-notes` in den Nextcloud-Dateien abgeglichen; offline arbeitet die App normal weiter. Wurde eine Seite auf zwei Geräten geändert, bleiben beide Fassungen erhalten (die andere als eigene Seite); gelöschte Notizbücher und Seiten landen im Papierkorb (lokal `.trash`, auf dem Server dessen Papierkorb). Einem selbst signierten Zertifikat vertraut die App, nachdem du seinen Fingerabdruck bestätigt hast; Adressen mit `http://` gehen nur mit Warnung, weil das App-Passwort dann im Netz lesbar wäre. „Trennen“ beendet den Abgleich und widerruft das App-Passwort; die Notizbücher bleiben erhalten. Bitte entweder den eingebauten Abgleich oder den Ordner eines Sync-Clients als Speicherort verwenden, nicht beides für dieselben Notizbücher.
### Export
- **PDF** (`Strg + E`) nach `~/Documents/<Titel>.pdf`: Striche und Formen als Vektoren, Textfelder als echter, durchsuchbarer Text, importierte PDF-Seiten als Vektor-Unterlage, Seitentitel als Lesezeichen.
@@ -87,7 +93,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
## Installation
Pakete gibt es unter [Releases](https://gitea.pansi.eu/harald/ink-notes/releases) als `.deb`, `.AppImage` und für Windows als `.exe` (x86_64). `<version>` steht für die Versionsnummer, z. B. `0.11.0`.
Pakete gibt es unter [Releases](https://gitea.pansi.eu/harald/ink-notes/releases) als `.deb`, `.AppImage`, für Windows als `.exe` (x86_64) und für Android als `.apk` (arm64). `<version>` steht für die Versionsnummer, z. B. `0.11.0`.
```bash
# Debian-Paket (Zorin OS 18 / Ubuntu 24.04)
@@ -104,6 +110,8 @@ Hinweise:
**Windows (experimentell):** `ink-notes_<version>_x86_64.exe` ist ein portables Programm für Windows 10/11 – ohne Installation einfach starten (Windows SmartScreen fragt evtl. einmal nach, da das Programm nicht signiert ist). Stifteingabe mit Druck läuft über Windows Ink (noch nicht auf echter Hardware getestet). Notizbücher und Einstellungen liegen in `%APPDATA%\ink-notes`. Updates ersetzen die `.exe` an Ort und Stelle und gelten ab dem nächsten Start.
**Android:** `ink-notes_<version>_arm64.apk` läuft ab Android 8 (64-Bit-ARM). Auf dem Gerät herunterladen und öffnen; Android fragt einmal, ob Browser oder Dateimanager Apps installieren dürfen. Der Stift wird von Fingern unterschieden (Stifttasten, Radierer-Ende, Handballen-Erkennung), Import und „Bild einfügen“ nutzen den System-Dateidialog, und Exporte landen in `Download/ink-notes` (ab Android 10). Notizbücher liegen im eigenen Speicher der App und werden beim Deinstallieren gelöscht – vorher exportieren, was bleiben soll. Ein Update in der App gibt es nicht: die neue APK über die alte installieren, die Notizbücher bleiben erhalten.
---
## Tastenkürzel
@@ -156,7 +164,7 @@ Die CI prüft zusätzlich `cargo fmt --check` und `cargo clippy --all-targets --
Andere Systeme:
- **Windows** wird unter Linux übersetzt: `scripts/package-windows.sh` (braucht `gcc-mingw-w64-x86-64`); die Tests laufen mit `wine` als Runner (`CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUNNER=wine cargo test --target x86_64-pc-windows-gnu`).
- **Android** (in Arbeit, Issue #42): `cargo check --target aarch64-linux-android` und `cargo rustc --lib --crate-type cdylib --target aarch64-linux-android` mit dem NDK-Compiler in `CC_aarch64_linux_android`. Dort startet die App noch nicht: Die Fensterfläche muss erst dem Lebenszyklus der Activity folgen.
- **Android** (Issue #42): `scripts/package-android.sh` baut `target/ink-notes_<version>_arm64.apk` ohne Gradle. Nötig sind das Android-SDK (build-tools, Plattform 34; `ANDROID_HOME`, Standard `~/android-sdk`) und das NDK; der Signierschlüssel kommt aus `ANDROID_KEYSTORE`, `ANDROID_KEYSTORE_PASS` und `ANDROID_KEY_ALIAS`, sonst wird der Debug-Schlüssel verwendet. Ohne SDK auf dem Rechner baut `scripts/build-apk-container.sh` sie in einem Container (podman oder docker, `packaging/android/Containerfile`); `scripts/setup-android-sdk.sh` installiert die festen SDK-/NDK-Versionen. Der CI-Job `android` baut die APK zu jedem Release und legt sie ins Release und in `SHA256SUMS.txt`; der Release-Schlüssel gehört in die Secrets `ANDROID_KEYSTORE_B64` (`base64 -w0 release.keystore`), `ANDROID_KEYSTORE_PASS` und `ANDROID_KEY_ALIAS`. Installieren mit `adb install -r target/ink-notes_<version>_arm64.apk`, Log-Ausgaben mit `adb logcat -s ink-notes`. Der Stift wird von Fingern unterschieden (Stifttasten, Radierer-Ende, Handballen-Erkennung), Textfelder öffnen die Bildschirmtastatur, Import und „Bild einfügen“ nutzen den System-Dateidialog, und Exporte landen in `Download/ink-notes` (ab Android 10). Tastaturtext und Dateidialog kommen von der kleinen Java-Activity `packaging/android/java/…/InkActivity.java` (zum Bauen ist ein JDK nötig).
### Projektstruktur
@@ -188,7 +196,12 @@ src/
├── history.rs # Rückgängig/Wiederholen
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb, Speicherort
├── disk_watch.rs # Änderungen anderer Geräte übernehmen (Sync-Clients)
├── nextcloud.rs # Nextcloud: Anmeldung im Browser, WebDAV, gemerkte Zertifikate
├── cloud_sync.rs # Eingebauter Abgleich mit Nextcloud (Vergleich, Zusammenführen, Hintergrund)
├── library.rs # Ordner der Notizbücher (library.json)
├── math.rs # Exaktes Ausrechnen handgeschriebener Formeln (Issue #46)
├── math_ink.rs # Erkennung handgeschriebener Formeln ($P-Punktwolken)
├── math_assist.rs # Ergebnis neben einem handgeschriebenen „=“ (Auslöser, Vorschlag)
├── autosave.rs # Speichern im Hintergrund
├── export.rs # PDF-Export
├── page_export.rs # Seite als PNG/SVG, Auswahl als Bild
+16 -3
View File
@@ -13,7 +13,7 @@
ink-notes stores handwriting as vectors, can recognize shapes, imports PDFs for annotation as well as Xournal++, Rnote and Saber notebooks, inserts images and text boxes, and exports everything again as PDF, PNG or SVG. Saving happens automatically in the background.
It is mainly tested on Zorin OS 18 / Ubuntu 24.04 (Wayland and X11). A Windows version is available as an experimental portable program.
It is mainly tested on Zorin OS 18 / Ubuntu 24.04 (Wayland and X11). A Windows version is available as an experimental portable program, and an Android version as an APK.
The interface is available in English and German; ink-notes follows the system language, and you can switch it in the ☰ menu under "Sprache / Language".
@@ -37,6 +37,11 @@ The interface is available in English and German; ink-notes follows the system l
- Recognized shapes: lines, arrows, circles, ellipses, rectangles, triangles, rhombuses, polygons, coordinate systems (L shape), axis crosses (a "+" in one stroke or two crossing straight lines, arrowheads on all four ends) and octahedra (sketch as a 3D solid).
- Recognition is purely geometric (arc length, Douglas-Peucker, circle fitting), without machine learning. Not everything is recognized – whatever does not fit stays handwriting.
### Computing formulas (experimental)
- Turn on in the ☰ menu under input ("Compute formulas after “=”"). After a handwritten `=` and a short pause, the result appears next to it together with what was read, e.g. `2⁴ = 16` or `(x + 3)² = x² + 6x + 9`; tapping it with the pen or clicking inserts it as a text box in the ink color, as high as the handwriting (undo removes it). Fingers and palms do not trigger it.
- Read are digits, `+ − · : /`, parentheses, exponents (written raised), fraction bars, decimal commas and the variables `x`, `y`, `a`, `b`. Numbers are computed exactly as fractions, expressions with variables are multiplied out. Leave some space before the formula, so that writing further to the left is not read with it; in a chain such as `x = 2 · 3 =` the part after the previous `=` is computed.
- The symbols are recognized with point-cloud templates ($P), without machine learning and offline. If the suggestion shows a wrong symbol, just ignore it.
### Selecting and editing
- **Selection tool** (`V`): tap or drag a frame, then move, scale by the corners, rotate by the handle, or rotate by 90° with `R`. Works with mouse, finger and pen.
- **Recolor** the selection with the palette, **copy/paste** (`Ctrl + C/V`), **delete** (`Del`).
@@ -69,6 +74,7 @@ The interface is available in English and German; ink-notes follows the system l
- **When closing** (window or `Ctrl + Q`) and when switching notebooks, everything pending is written before continuing. If that fails, the app stays open and offers to retry or to make an emergency backup.
- `Ctrl + S` saves immediately. The dot on the ☰ button shows the state: green = saved, orange = changes pending, yellow = saving, red = error.
- **Storage location** (☰ menu, "Storage location"): put the notebooks into a folder that **Nextcloud, Google Drive, Syncthing or Dropbox** keep in sync, and they are available on all your devices. The notebooks are kept in the subfolder `ink-notes`; if it is still empty, the existing notebooks are copied there (the old ones stay). Changes arriving from another device show up within a few seconds without reopening – but only while everything local is saved, so nothing gets overwritten. If the sync client makes a conflict copy of a page, it is added as a page of its own („Conflict copy: …“) instead of getting lost. Opening a notebook that is open on another device shows a warning.
- **Nextcloud built in** (☰ menu, "Nextcloud"; also on Android and without a sync client): enter the address of your Nextcloud – also a private server, in a subfolder (`example.org/nextcloud`) or with its own port. Signing in happens in the browser (also with two-factor sign-in); the app gets an app password of its own, which you can revoke in Nextcloud under Settings → Security at any time, and never sees your real password. The notebooks are synced with the folder `ink-notes` in your Nextcloud files every 30 seconds and shortly after saving; the app keeps working offline. If a page was changed on two devices, both versions are kept (the other one as a page of its own); deleted notebooks and pages go into the trash (locally `.trash`, on the server its trash bin). A self-signed certificate is trusted once you have confirmed its fingerprint; addresses with `http://` only work with a warning, since the app password would be readable on the network. "Disconnect" stops syncing and revokes the app password; the notebooks stay. Use either the built-in sync or a sync client's folder as storage location, not both for the same notebooks.
### Export
- **PDF** (`Ctrl + E`) to `~/Documents/<title>.pdf`: strokes and shapes as vectors, text boxes as real, searchable text, imported PDF pages as vector background, page titles as bookmarks.
@@ -87,7 +93,7 @@ The interface is available in English and German; ink-notes follows the system l
## Installation
Packages are available under [Releases](https://gitea.pansi.eu/harald/ink-notes/releases) as `.deb`, `.AppImage` and for Windows as `.exe` (x86_64). `<version>` stands for the version number, e.g. `0.30.2`.
Packages are available under [Releases](https://gitea.pansi.eu/harald/ink-notes/releases) as `.deb`, `.AppImage`, for Windows as `.exe` (x86_64) and for Android as `.apk` (arm64). `<version>` stands for the version number, e.g. `0.30.2`.
```bash
# Debian package (Zorin OS 18 / Ubuntu 24.04)
@@ -104,6 +110,8 @@ Notes:
**Windows (experimental):** `ink-notes_<version>_x86_64.exe` is a portable program for Windows 10/11 – no installation, just start it (Windows SmartScreen may ask once, as the program is not signed). Pen input with pressure uses Windows Ink (not yet tested on real hardware). Notebooks and settings are stored in `%APPDATA%\ink-notes`. Updates replace the `.exe` in place and take effect at the next start.
**Android:** `ink-notes_<version>_arm64.apk` runs on Android 8 and newer (64-bit ARM). Download it on the device and open it; Android asks once to allow installing apps from the browser or file manager. The pen is told apart from fingers (pen buttons, eraser end, palm rejection), import and inserting images use the system file dialog, and exports are saved to `Download/ink-notes` (Android 10 and newer). Notebooks are stored in the app's own storage and are removed when the app is uninstalled – export what you want to keep first. There is no in-app update: install the new APK over the old one, the notebooks stay.
---
## Keyboard shortcuts
@@ -156,7 +164,7 @@ CI additionally checks `cargo fmt --check` and `cargo clippy --all-targets -- -D
Other systems:
- **Windows** is cross-compiled on Linux: `scripts/package-windows.sh` (needs `gcc-mingw-w64-x86-64`); the tests run with `wine` as runner (`CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUNNER=wine cargo test --target x86_64-pc-windows-gnu`).
- **Android** (in progress, Issue #42): `cargo check --target aarch64-linux-android` and `cargo rustc --lib --crate-type cdylib --target aarch64-linux-android` with the NDK compiler in `CC_aarch64_linux_android`. The app does not start there yet: the window surface still has to follow the activity lifecycle.
- **Android** (Issue #42): `scripts/package-android.sh` builds `target/ink-notes_<version>_arm64.apk` without Gradle. It needs the Android SDK (build-tools, platform 34; `ANDROID_HOME`, default `~/android-sdk`) and the NDK; the signing key comes from `ANDROID_KEYSTORE`, `ANDROID_KEYSTORE_PASS` and `ANDROID_KEY_ALIAS`, otherwise the debug key is used. Without SDK on the host, `scripts/build-apk-container.sh` builds it in a container (podman or docker, `packaging/android/Containerfile`); `scripts/setup-android-sdk.sh` installs the fixed SDK/NDK versions. The CI job `android` builds the APK for every release and adds it to the release and to `SHA256SUMS.txt`; the release key goes into the secrets `ANDROID_KEYSTORE_B64` (`base64 -w0 release.keystore`), `ANDROID_KEYSTORE_PASS` and `ANDROID_KEY_ALIAS`. Install with `adb install -r target/ink-notes_<version>_arm64.apk`, log output with `adb logcat -s ink-notes`. The pen is told apart from fingers (pen buttons, eraser end, palm rejection), text fields open the soft keyboard, import and inserting images use the system file dialog, and exports are saved to `Download/ink-notes` (Android 10 and newer). The small Java activity `packaging/android/java/…/InkActivity.java` (needs a JDK to build) provides keyboard text and the file dialog.
New code comments and commit messages are written in English; older code and history still contain German.
@@ -190,7 +198,12 @@ src/
├── history.rs # Undo/redo
├── storage.rs # Loading/saving (atomic), notebooks, trash, storage location
├── disk_watch.rs # Taking over changes from other devices (sync clients)
├── nextcloud.rs # Nextcloud: sign-in in the browser, WebDAV, pinned certificates
├── cloud_sync.rs # Built-in sync with Nextcloud (comparison, merging, background thread)
├── library.rs # Notebook folders (library.json)
├── math.rs # Exact evaluation of handwritten math (Issue #46)
├── math_ink.rs # Recognition of handwritten math ($P point clouds)
├── math_assist.rs # Result next to a handwritten "=" (trigger, suggestion)
├── autosave.rs # Saving in the background
├── export.rs # PDF export
├── page_export.rs # Page as PNG/SVG, selection as image
+76 -30
View File
@@ -19,6 +19,12 @@
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
</radialGradient>
<!-- Highlight Card Gradient -->
<linearGradient id="highlightGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#132644"/>
<stop offset="100%" stop-color="#0d1b32"/>
</linearGradient>
<!-- Icon Hintergrund-Verlauf -->
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e2638"/>
@@ -121,58 +127,98 @@
<!-- ==================== RECHTER BEREICH: TYPOGRAFIE & BADGES ==================== -->
<g transform="translate(360, 0)">
<!-- Version & Platform Pill -->
<g transform="translate(0, 68)">
<rect width="214" height="30" rx="15" fill="#1e293b" stroke="#334155" stroke-width="1.2"/>
<circle cx="16" cy="15" r="4" fill="#22c55e"/>
<text x="28" y="20" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="600" fill="#cbd5e1" letter-spacing="0.5">
LINUX • RUST &amp; WGPU
<!-- Top Badges -->
<g transform="translate(0, 56)">
<!-- Platform Pill (No version number) -->
<rect width="248" height="28" rx="14" fill="#1e293b" stroke="#334155" stroke-width="1.2"/>
<circle cx="15" cy="14" r="4" fill="#22c55e"/>
<text x="27" y="18.5" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="12" font-weight="600" fill="#cbd5e1" letter-spacing="0.6">
LINUX • WINDOWS • ANDROID
</text>
<!-- Engine Pill -->
<g transform="translate(260, 0)">
<rect width="138" height="28" rx="14" fill="#1e293b" stroke="#334155" stroke-width="1.2"/>
<text x="13" y="18.5" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="11.5" font-weight="600" fill="#94a3b8" letter-spacing="0.5">
⚡ RUST &amp; WGPU
</text>
</g>
<!-- Major Update Pill -->
<g transform="translate(410, 0)">
<rect width="144" height="28" rx="14" fill="url(#highlightGrad)" stroke="#0ea5e9" stroke-width="1.2"/>
<circle cx="15" cy="14" r="3.5" fill="#38bdf8"/>
<text x="27" y="18.5" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="11.5" font-weight="700" fill="#38bdf8" letter-spacing="0.5">
MAJOR UPDATE
</text>
</g>
</g>
<!-- Haupttitel: ink-notes -->
<text x="0" y="162" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="800" font-size="64" letter-spacing="-1">
<text x="0" y="140" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="800" font-size="58" letter-spacing="-1">
<tspan fill="url(#textInkGrad)">ink</tspan><tspan fill="#f8fafc">-notes</tspan>
</text>
<!-- Subtitle / Tagline -->
<text x="0" y="206" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="400" font-size="22" fill="#94a3b8">
<text x="0" y="178" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="400" font-size="20" fill="#94a3b8">
Notebook &amp; PDF annotation app for pen, touch and mouse
</text>
<!-- Feature Pills / Highlights -->
<g transform="translate(0, 248)">
<!-- Pill 1: Niedrige Latenz -->
<!-- Feature Highlights: Row 1 (Brand New Major Changes) -->
<g transform="translate(0, 214)">
<!-- Pill 1: Windows & Android Support -->
<g transform="translate(0, 0)">
<rect width="152" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
⚡ Fast wgpu inking
<rect width="250" height="36" rx="10" fill="url(#highlightGrad)" stroke="#0ea5e9" stroke-width="1.2"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="600" fill="#7dd3fc">
✨ Windows &amp; Android Support
</text>
</g>
<!-- Pill 2: Stylus mit Druck -->
<g transform="translate(164, 0)">
<rect width="214" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
🖊️ Pressure &amp; palm rejection
<!-- Pill 2: New Import Functions -->
<g transform="translate(262, 0)">
<rect width="348" height="36" rx="10" fill="url(#highlightGrad)" stroke="#0ea5e9" stroke-width="1.2"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="600" fill="#7dd3fc">
📥 Import: Xournal++, Rnote, Saber &amp; PDF
</text>
</g>
<!-- Pill 3: Formerkennung -->
<g transform="translate(390, 0)">
<rect width="216" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
📐 Shape recognition &amp; ruler
<!-- Pill 3: Fast Inking -->
<g transform="translate(622, 0)">
<rect width="160" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="500" fill="#93c5fd">
⚡ Fast wgpu Inking
</text>
</g>
</g>
<!-- Feature Highlights: Row 2 (Core Strengths) -->
<g transform="translate(0, 262)">
<!-- Pill 4: Pressure & Palm Rejection with Vector Stylus Icon -->
<g transform="translate(0, 0)">
<rect width="248" height="34" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<svg x="14" y="9" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#38bdf8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/>
</svg>
<text x="36" y="22" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="500" fill="#93c5fd">
Pressure &amp; Palm Rejection
</text>
</g>
<!-- Pill 4: 100% lokal -->
<g transform="translate(618, 0)">
<rect width="188" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
🔒 100% local &amp; atomic
<!-- Pill 5: Shape Recognition & Ruler -->
<g transform="translate(260, 0)">
<rect width="254" height="34" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="22" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="500" fill="#93c5fd">
📐 Shape Recognition &amp; Ruler
</text>
</g>
<!-- Pill 6: 100% Local & Atomic -->
<g transform="translate(526, 0)">
<rect width="256" height="34" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="22" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="500" fill="#93c5fd">
🔒 100% Local, Atomic &amp; Private
</text>
</g>
</g>
</g>
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+75 -29
View File
@@ -19,6 +19,12 @@
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
</radialGradient>
<!-- Highlight Card Gradient -->
<linearGradient id="highlightGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#132644"/>
<stop offset="100%" stop-color="#0d1b32"/>
</linearGradient>
<!-- Icon Hintergrund-Verlauf -->
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e2638"/>
@@ -121,58 +127,98 @@
<!-- ==================== RECHTER BEREICH: TYPOGRAFIE & BADGES ==================== -->
<g transform="translate(360, 0)">
<!-- Version & Platform Pill -->
<g transform="translate(0, 68)">
<rect width="254" height="30" rx="15" fill="#1e293b" stroke="#334155" stroke-width="1.2"/>
<circle cx="16" cy="15" r="4" fill="#22c55e"/>
<text x="28" y="20" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="600" fill="#cbd5e1" letter-spacing="0.5">
v0.14.0 • LINUX • RUST &amp; WGPU
<!-- Top Badges -->
<g transform="translate(0, 56)">
<!-- Platform Pill (ohne Versionsnummer) -->
<rect width="248" height="28" rx="14" fill="#1e293b" stroke="#334155" stroke-width="1.2"/>
<circle cx="15" cy="14" r="4" fill="#22c55e"/>
<text x="27" y="18.5" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="12" font-weight="600" fill="#cbd5e1" letter-spacing="0.6">
LINUX • WINDOWS • ANDROID
</text>
<!-- Engine Pill -->
<g transform="translate(260, 0)">
<rect width="138" height="28" rx="14" fill="#1e293b" stroke="#334155" stroke-width="1.2"/>
<text x="13" y="18.5" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="11.5" font-weight="600" fill="#94a3b8" letter-spacing="0.5">
⚡ RUST &amp; WGPU
</text>
</g>
<!-- Major Update Pill -->
<g transform="translate(410, 0)">
<rect width="164" height="28" rx="14" fill="url(#highlightGrad)" stroke="#0ea5e9" stroke-width="1.2"/>
<circle cx="15" cy="14" r="3.5" fill="#38bdf8"/>
<text x="27" y="18.5" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="11.5" font-weight="700" fill="#38bdf8" letter-spacing="0.5">
GROSSES UPDATE
</text>
</g>
</g>
<!-- Haupttitel: ink-notes -->
<text x="0" y="162" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="800" font-size="64" letter-spacing="-1">
<text x="0" y="140" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="800" font-size="58" letter-spacing="-1">
<tspan fill="url(#textInkGrad)">ink</tspan><tspan fill="#f8fafc">-notes</tspan>
</text>
<!-- Subtitle / Tagline -->
<text x="0" y="206" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="400" font-size="22" fill="#94a3b8">
<text x="0" y="178" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="400" font-size="20" fill="#94a3b8">
Notizbuch- &amp; PDF-Annotations-App für Stift, Touch und Maus
</text>
<!-- Feature Pills / Highlights -->
<g transform="translate(0, 248)">
<!-- Pill 1: Niedrige Latenz -->
<!-- Feature Highlights: Row 1 (Brandneue Hauptfeatures) -->
<g transform="translate(0, 214)">
<!-- Pill 1: Windows & Android Support -->
<g transform="translate(0, 0)">
<rect width="186" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
⚡ Schnelles wgpu-Inking
<rect width="254" height="36" rx="10" fill="url(#highlightGrad)" stroke="#0ea5e9" stroke-width="1.2"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="600" fill="#7dd3fc">
✨ Windows- &amp; Android-Support
</text>
</g>
<!-- Pill 2: Stylus mit Druck -->
<g transform="translate(198, 0)">
<rect width="190" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
🖊️ Druck &amp; Palm Rejection
<!-- Pill 2: Neue Import-Funktionen -->
<g transform="translate(266, 0)">
<rect width="348" height="36" rx="10" fill="url(#highlightGrad)" stroke="#0ea5e9" stroke-width="1.2"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="600" fill="#7dd3fc">
📥 Import: Xournal++, Rnote, Saber &amp; PDF
</text>
</g>
<!-- Pill 3: Formerkennung -->
<g transform="translate(400, 0)">
<rect width="182" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
<!-- Pill 3: Schnelles wgpu-Inking -->
<g transform="translate(626, 0)">
<rect width="156" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="500" fill="#93c5fd">
⚡ wgpu-Inking
</text>
</g>
</g>
<!-- Feature Highlights: Row 2 (Kernfunktionen) -->
<g transform="translate(0, 262)">
<!-- Pill 4: Stiftdruck & Palm Rejection with Vector Stylus Icon -->
<g transform="translate(0, 0)">
<rect width="248" height="34" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<svg x="14" y="9" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#38bdf8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/>
</svg>
<text x="36" y="22" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="500" fill="#93c5fd">
Druck &amp; Palm Rejection
</text>
</g>
<!-- Pill 5: Formenerkennung & Lineal -->
<g transform="translate(260, 0)">
<rect width="254" height="34" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="22" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="500" fill="#93c5fd">
📐 Formenerkennung &amp; Lineal
</text>
</g>
<!-- Pill 4: 100% lokal -->
<g transform="translate(594, 0)">
<rect width="176" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
🔒 100% lokal &amp; atomar
<!-- Pill 6: 100% lokal, atomar & privat -->
<g transform="translate(526, 0)">
<rect width="256" height="34" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
<text x="14" y="22" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="500" fill="#93c5fd">
🔒 100% lokal, atomar &amp; privat
</text>
</g>
</g>
</g>
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Manifest of the Android app (Issue #42). scripts/package-android.sh fills in the version.
InkActivity (packaging/android/java) extends NativeActivity, which loads libink_notes.so;
its android_main runs the app. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="eu.pansi.inknotes"
android:versionCode="@VERSION_CODE@"
android:versionName="@VERSION@">
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<application
android:label="ink-notes"
android:icon="@mipmap/ic_launcher"
android:hasCode="true"
android:extractNativeLibs="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<!-- configChanges: rotating the tablet or attaching a keyboard must not restart the
activity; the app adapts to the new window size itself -->
<activity
android:name="eu.pansi.inknotes.InkActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|density|uiMode|fontScale|locale|layoutDirection">
<meta-data android:name="android.app.lib_name" android:value="ink_notes" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
+25
View File
@@ -0,0 +1,25 @@
# Build environment for the Android APK (Issue #45): fixed versions of the JDK, Android SDK,
# NDK and Rust, so that the APK can be built the same way anywhere.
# Used by scripts/build-apk-container.sh (podman or docker).
FROM docker.io/library/ubuntu:24.04
ARG RUST_VERSION=1.99.0
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates curl unzip zip openjdk-17-jdk-headless build-essential pkg-config \
&& rm -rf /var/lib/apt/lists/*
ENV ANDROID_HOME=/opt/android-sdk \
RUSTUP_HOME=/opt/rustup \
CARGO_HOME=/opt/cargo \
PATH=/opt/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
COPY scripts/setup-android-sdk.sh /tmp/setup-android-sdk.sh
RUN /tmp/setup-android-sdk.sh && rm /tmp/setup-android-sdk.sh
RUN curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal \
--default-toolchain "${RUST_VERSION}" --target aarch64-linux-android \
&& chmod -R a+rwX /opt/cargo /opt/rustup
WORKDIR /src
@@ -0,0 +1,237 @@
package eu.pansi.inknotes;
import android.app.NativeActivity;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.provider.OpenableColumns;
import android.text.InputType;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.view.inputmethod.BaseInputConnection;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputMethodManager;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
/**
* The app's activity (Issue #42): NativeActivity runs the Rust code (android_main in
* libink_notes.so); this class adds what NativeActivity lacks.
*
* - Text input: NativeActivity's view offers keyboards no input connection, so they can only
* send key events, and characters without a key (umlauts chosen by long press, ...) got lost.
* An invisible view with its own input connection passes committed text to Rust.
* - Picking files: the system file dialog (ACTION_OPEN_DOCUMENT); the chosen file is copied to
* the cache folder, as Rust can only open files by path.
* - System bars: NativeActivity draws the whole window, also below the status and navigation
* bars and display cutouts. Their sizes go to Rust, which keeps its buttons out of them
* (Issue #50).
*
* Rust calls showKeyboard and pickFile through JNI and receives the native* calls
* (src/android_bridge.rs).
*/
public class InkActivity extends NativeActivity {
static {
// NativeActivity loads the library too, but without System.loadLibrary the native
// methods below would not be found
System.loadLibrary("ink_notes");
}
private static final int PICK_FILE = 1;
static native void nativeCommitText(String text);
static native void nativeDeleteSurroundingText(int before, int after);
/** Path of the copied file, or null if nothing was chosen or copying failed. */
static native void nativeFilePicked(String path);
/** Window edges covered by system bars or cutouts, in pixels. */
static native void nativeSetInsets(int left, int top, int right, int bottom);
private TextSink textSink;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
textSink = new TextSink(this);
addContentView(textSink, new ViewGroup.LayoutParams(1, 1));
View decor = getWindow().getDecorView();
decor.setOnApplyWindowInsetsListener((view, insets) -> {
reportInsets(insets);
return view.onApplyWindowInsets(insets);
});
decor.requestApplyInsets();
}
@SuppressWarnings("deprecation")
private static void reportInsets(WindowInsets insets) {
if (Build.VERSION.SDK_INT >= 30) {
android.graphics.Insets bars = insets.getInsets(
WindowInsets.Type.systemBars() | WindowInsets.Type.displayCutout());
nativeSetInsets(bars.left, bars.top, bars.right, bars.bottom);
} else {
// Before Android 11 the system window insets include the bars (and since
// Android 9 the cutout)
nativeSetInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(),
insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom());
}
}
/** Shows or hides the soft keyboard; called from Rust on any thread. */
public void showKeyboard(final boolean show) {
runOnUiThread(() -> {
InputMethodManager imm =
(InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
if (show) {
textSink.requestFocus();
imm.restartInput(textSink);
imm.showSoftInput(textSink, 0);
} else {
imm.hideSoftInputFromWindow(textSink.getWindowToken(), 0);
textSink.clearFocus();
}
});
}
/** Opens the system file dialog for files of this MIME type ("image/*", "*" + "/*"). */
public void pickFile(final String mimeType) {
runOnUiThread(() -> {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType(mimeType);
try {
startActivityForResult(intent, PICK_FILE);
} catch (Exception e) {
nativeFilePicked(null);
}
});
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode != PICK_FILE) {
return;
}
final Uri uri = (resultCode == RESULT_OK && data != null) ? data.getData() : null;
if (uri == null) {
nativeFilePicked(null);
return;
}
// Copying a large file must not block the UI thread
new Thread(() -> {
String path = null;
try {
path = copyToCache(uri);
} catch (Exception e) {
android.util.Log.w("ink-notes", "copying the picked file failed", e);
}
nativeFilePicked(path);
}).start();
}
private String copyToCache(Uri uri) throws Exception {
String name = "import";
try (Cursor cursor = getContentResolver().query(
uri, new String[] {OpenableColumns.DISPLAY_NAME}, null, null, null)) {
if (cursor != null && cursor.moveToFirst() && !cursor.isNull(0)) {
name = cursor.getString(0).replace('/', '_');
}
}
File dir = new File(getCacheDir(), "picked");
File[] old = dir.listFiles();
if (old != null) {
for (File f : old) {
f.delete();
}
}
dir.mkdirs();
File target = new File(dir, name);
try (InputStream in = getContentResolver().openInputStream(uri);
OutputStream out = new FileOutputStream(target)) {
byte[] buffer = new byte[64 * 1024];
int n;
while ((n = in.read(buffer)) > 0) {
out.write(buffer, 0, n);
}
}
return target.getAbsolutePath();
}
/** Invisible view that receives the keyboard's text. */
private static class TextSink extends View {
TextSink(Context context) {
super(context);
setFocusable(true);
setFocusableInTouchMode(true);
}
@Override
public boolean onCheckIsTextEditor() {
return true;
}
@Override
public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
// Visible password: keyboards commit every character right away instead of
// composing words with suggestions, which the app could not show
outAttrs.inputType = InputType.TYPE_CLASS_TEXT
| InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
| InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
| InputType.TYPE_TEXT_FLAG_MULTI_LINE;
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI
| EditorInfo.IME_FLAG_NO_FULLSCREEN
| EditorInfo.IME_ACTION_NONE;
return new TextConnection(this);
}
}
/** Passes committed text and deletions to Rust; key events go the usual way. */
private static class TextConnection extends BaseInputConnection {
private String composing = "";
TextConnection(View view) {
super(view, false);
}
@Override
public boolean commitText(CharSequence text, int newCursorPosition) {
composing = "";
if (text.length() > 0) {
nativeCommitText(text.toString());
}
return true;
}
@Override
public boolean setComposingText(CharSequence text, int newCursorPosition) {
// Not shown while composing; taken over once finished
composing = text.toString();
return true;
}
@Override
public boolean finishComposingText() {
if (!composing.isEmpty()) {
nativeCommitText(composing);
composing = "";
}
return true;
}
@Override
public boolean deleteSurroundingText(int beforeLength, int afterLength) {
nativeDeleteSurroundingText(beforeLength, afterLength);
return true;
}
}
}
+25
View File
@@ -84,6 +84,31 @@
</branding>
<content_rating type="oars-1.1" />
<releases>
<release version="0.42.0" date="2026-10-28">
<description>
<p>Built-in Nextcloud sync: sign in through the browser and the notebooks are synced with your Nextcloud, also private servers and without a sync client.</p>
</description>
</release>
<release version="0.41.1" date="2026-10-28">
<description>
<p>Android: the toolbar no longer hides behind the status bar and the navigation bar.</p>
</description>
</release>
<release version="0.41.0" date="2026-10-28">
<description>
<p>Experimental: computes handwritten formulas after an equals sign and offers the result next to it (turn on in the menu under input).</p>
</description>
</release>
<release version="0.40.0" date="2026-10-28">
<description>
<p>New Android version: a signed APK for 64-bit ARM devices with pen, touch, soft keyboard, file import and exports to Downloads.</p>
</description>
</release>
<release version="0.39.3" date="2026-10-27">
<description>
<p>Much lower battery use: the app no longer redraws the window continuously while nothing happens.</p>
</description>
</release>
<release version="0.39.2" date="2026-10-27">
<description>
<p>PDF export now uses its own small PDF writer on lopdf 0.46 instead of printpdf; the exported files look the same.</p>
+5 -5
View File
@@ -3655,14 +3655,14 @@
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/rustls/rustls-0.23.45.crate",
"sha256": "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634",
"dest": "cargo/vendor/rustls-0.23.45"
"url": "https://static.crates.io/crates/rustls/rustls-0.23.46.crate",
"sha256": "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf",
"dest": "cargo/vendor/rustls-0.23.46"
},
{
"type": "inline",
"contents": "{\"package\": \"0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634\", \"files\": {}}",
"dest": "cargo/vendor/rustls-0.23.45",
"contents": "{\"package\": \"48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf\", \"files\": {}}",
"dest": "cargo/vendor/rustls-0.23.46",
"dest-filename": ".cargo-checksum.json"
},
{
+25
View File
@@ -84,6 +84,31 @@
</branding>
<content_rating type="oars-1.1" />
<releases>
<release version="0.42.0" date="2026-10-28">
<description>
<p>Built-in Nextcloud sync: sign in through the browser and the notebooks are synced with your Nextcloud, also private servers and without a sync client.</p>
</description>
</release>
<release version="0.41.1" date="2026-10-28">
<description>
<p>Android: the toolbar no longer hides behind the status bar and the navigation bar.</p>
</description>
</release>
<release version="0.41.0" date="2026-10-28">
<description>
<p>Experimental: computes handwritten formulas after an equals sign and offers the result next to it (turn on in the menu under input).</p>
</description>
</release>
<release version="0.40.0" date="2026-10-28">
<description>
<p>New Android version: a signed APK for 64-bit ARM devices with pen, touch, soft keyboard, file import and exports to Downloads.</p>
</description>
</release>
<release version="0.39.3" date="2026-10-27">
<description>
<p>Much lower battery use: the app no longer redraws the window continuously while nothing happens.</p>
</description>
</release>
<release version="0.39.2" date="2026-10-27">
<description>
<p>PDF export now uses its own small PDF writer on lopdf 0.46 instead of printpdf; the exported files look the same.</p>
+6
View File
@@ -3,6 +3,12 @@
Notebook app for pen, touch and mouse on Linux (tested on Zorin OS 18 / Ubuntu 24.04).
### Highlights:
- **Nextcloud built in**: ☰ → Storage location → Nextcloud syncs the notebooks directly with your Nextcloud – also on Android and without a sync client. Enter the server address (private servers in a subfolder, with their own port or a self-signed certificate work too), sign in in the browser; the app gets an app password of its own that "Disconnect" revokes again. Syncing runs every 30 seconds and shortly after saving and keeps working offline; a page changed on two devices is kept twice, deleted notebooks and pages go into the trash on both sides, changes arriving for the open notebook wait until it is saved (Issue #49)
- **Security update**: rustls 0.23.46 (TLS library for update checks and the Nextcloud sync) fixes a way for key material to end up in logs (Issue #51); ink-notes itself was not affected, as it doesn't keep TLS sessions
- **Android toolbar fix**: The toolbar and menus stay clear of the status bar, the navigation bar and the camera cutout, so the topmost button (📚 notebooks) can be tapped again; in landscape the page is centered between the side bars. Nothing changes on Linux and Windows (Issue #50)
- **Computing formulas (experimental)**: Turn on in ☰ → Input ("Compute formulas after “=”"). After a handwritten `=` and a short pause, the result appears next to it together with what was read, e.g. `2⁴ = 16` or `(x + 3)² = x² + 6x + 9`; tapping it with the pen or clicking inserts it as text in the ink color and as high as the handwriting. Digits, `+ − · : /`, parentheses, exponents, fraction bars, decimal commas and `x`, `y`, `a`, `b` are read; numbers are computed exactly, expressions with variables multiplied out. Recognition runs offline without machine learning – feedback with real handwriting welcome (Issue #46)
- **Android**: `ink-notes_<version>_arm64.apk` brings ink-notes to Android 8 and newer (64-bit ARM) – download it on the device and open it to install. The pen is told apart from fingers (pressure, pen buttons, eraser end, palm rejection), text fields open the soft keyboard, import and inserting images use the system file dialog, and exports are saved to `Download/ink-notes` (Android 10 and newer). Notebooks live in the app's own storage and are removed when uninstalling; updates are installed by opening the new APK. Nothing changes on Linux and Windows
- **Battery drain fixed**: ink-notes redrew its window at full frame rate even when nothing happened, which kept the app and the desktop (e.g. gnome-shell) busy and drained the battery. It now only draws when something changes; idle CPU load drops to practically zero. Also applies to Windows. Internally, the Android version now follows the app lifecycle and can be packed into an APK (not part of the release yet)
- **PDF export on current lopdf**: The PDF export uses its own small PDF writer on top of lopdf 0.46 instead of the outdated printpdf 0.7 (which pinned lopdf 0.31). Exported PDFs look the same – layers, searchable text, images, highlighters and bookmarks stay as before
- **Windows mouse fix**: The mouse works normally again in the Windows version – Windows Ink reported it as a pen, so clicks and moves went the wrong way. Nothing changes on Linux
- **Windows (experimental)**: `ink-notes_<version>_x86_64.exe` is a portable program for Windows 10/11 – just start it, no installation. Notebooks and settings are stored in `%APPDATA%\ink-notes`, pen input uses Windows Ink, updates replace the `.exe` and take effect at the next start. Not yet tested on real tablet hardware – feedback welcome. Nothing changes on Linux; internally the groundwork for an Android version was laid
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# ==============================================================================
# Builds the APK inside the container from packaging/android/Containerfile (Issue #45),
# with podman (preferred) or docker. Needs nothing else on the host.
# Result: target/ink-notes_<version>_arm64.apk
#
# Signing key as in scripts/package-android.sh: ANDROID_KEYSTORE (path on the host),
# ANDROID_KEYSTORE_PASS, ANDROID_KEY_ALIAS; otherwise a debug key (kept below
# target/android-container/home, so that test builds can update each other).
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${ROOT_DIR}"
if command -v podman >/dev/null 2>&1; then
ENGINE=podman
USER_ARGS=(--userns=keep-id)
elif command -v docker >/dev/null 2>&1; then
ENGINE=docker
USER_ARGS=(--user "$(id -u):$(id -g)")
else
echo "FEHLER: weder podman noch docker gefunden."
exit 1
fi
IMAGE="ink-notes-android"
echo "=== Baue Container-Image ${IMAGE} ==="
"${ENGINE}" build -t "${IMAGE}" -f packaging/android/Containerfile .
mkdir -p target/android-container/home
ARGS=(
--rm "${USER_ARGS[@]}"
-v "${ROOT_DIR}:/src"
-e HOME=/src/target/android-container/home
-e CARGO_TARGET_DIR=/src/target/android-container
# Downloaded crates stay between runs
-e CARGO_HOME=/src/target/android-container/cargo
)
if [ -n "${ANDROID_KEYSTORE:-}" ]; then
ARGS+=(
-v "$(realpath "${ANDROID_KEYSTORE}"):/keystore:ro"
-e ANDROID_KEYSTORE=/keystore
-e ANDROID_KEYSTORE_PASS -e ANDROID_KEY_ALIAS
)
fi
"${ENGINE}" run "${ARGS[@]}" "${IMAGE}" scripts/package-android.sh
+12 -3
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# ==============================================================================
# Haupt-Release-Build-Skript für ink-notes (Zorin OS 18 / Linux)
# Erstellt .deb, .AppImage, Windows-.exe, SHA256-Prüfsummen und deren Minisign-Signatur in target/
# Erstellt .deb, .AppImage, Windows-.exe, (mit vorhandener APK) SHA256-Prüfsummen und deren Minisign-Signatur in target/
# ==============================================================================
set -euo pipefail
@@ -14,6 +14,8 @@ VERSION=$(grep -m1 '^version = ' Cargo.toml | cut -d '"' -f2)
DEB_NAME="ink-notes_${VERSION}_amd64.deb"
APPIMAGE_NAME="ink-notes_${VERSION}_x86_64.AppImage"
EXE_NAME="ink-notes_${VERSION}_x86_64.exe"
# Built separately (scripts/package-android.sh, CI job android); listed in the checksums if present
APK_NAME="ink-notes_${VERSION}_arm64.apk"
CHECKSUM_FILE="SHA256SUMS.txt"
echo "======================================================================"
@@ -32,7 +34,14 @@ echo "======================================================================"
# 4. Checksums berechnen
echo "=== Berechne SHA256-Prüfsummen in target/${CHECKSUM_FILE} ==="
cd "${ROOT_DIR}/target"
sha256sum "${DEB_NAME}" "${APPIMAGE_NAME}" "${EXE_NAME}" > "${CHECKSUM_FILE}"
RELEASE_FILES=("${DEB_NAME}" "${APPIMAGE_NAME}" "${EXE_NAME}")
if [ -f "${APK_NAME}" ]; then
RELEASE_FILES+=("${APK_NAME}")
elif [ "${INK_NOTES_REQUIRE_APK:-0}" = "1" ]; then
echo "FEHLER: target/${APK_NAME} fehlt (INK_NOTES_REQUIRE_APK=1)."
exit 1
fi
sha256sum "${RELEASE_FILES[@]}" > "${CHECKSUM_FILE}"
# 5. Prüfsummen signieren (Pflicht für In-App-Updates)
# Signiert wird mit rsign2 (minisign-kompatibles Format): Der Schlüssel wurde mit
@@ -77,7 +86,7 @@ fi
echo "======================================================================"
echo "Release-Artefakte für v${VERSION} erfolgreich erstellt:"
echo "======================================================================"
ls -lh "${DEB_NAME}" "${APPIMAGE_NAME}" "${EXE_NAME}" "${CHECKSUM_FILE}"
ls -lh "${RELEASE_FILES[@]}" "${CHECKSUM_FILE}"
if [ -f "${SIGNATURE_FILE}" ]; then ls -lh "${SIGNATURE_FILE}"; fi
echo ""
echo "Inhalt von target/${CHECKSUM_FILE}:"
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# ==============================================================================
# Builds the Android app target/ink-notes_<version>_arm64.apk (Issues #42, #45)
# without Gradle: the Rust library is built as cdylib with the NDK, javac and d8 compile the
# activity (packaging/android/java), aapt2 links the manifest and the icon, zipalign and
# apksigner finish the APK. Needs a JDK (javac, keytool).
#
# Needs the Android SDK (build-tools, platform android-34) and the NDK:
# ANDROID_HOME SDK folder (default: ~/android-sdk)
# ANDROID_NDK_HOME NDK folder (default: newest in $ANDROID_HOME/ndk)
#
# Signing key (all three, otherwise the debug key ~/.android/debug.keystore is used and
# created if missing; such an APK is only meant for testing):
# ANDROID_KEYSTORE, ANDROID_KEYSTORE_PASS, ANDROID_KEY_ALIAS
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${ROOT_DIR}"
TARGET="aarch64-linux-android"
ABI="arm64-v8a"
MIN_SDK=26
TARGET_SDK=34
VERSION=$(grep -m1 '^version = ' Cargo.toml | cut -d '"' -f2)
IFS=. read -r MAJOR MINOR PATCH <<<"${VERSION%%-*}"
VERSION_CODE=$((MAJOR * 10000 + MINOR * 100 + PATCH))
APK_NAME="ink-notes_${VERSION}_arm64.apk"
ANDROID_HOME="${ANDROID_HOME:-${HOME}/android-sdk}"
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
ANDROID_NDK_HOME=$(ls -d "${ANDROID_HOME}"/ndk/*/ 2>/dev/null | sort -V | tail -n1)
fi
BUILD_TOOLS=$(ls -d "${ANDROID_HOME}"/build-tools/*/ 2>/dev/null | sort -V | tail -n1)
ANDROID_JAR="${ANDROID_HOME}/platforms/android-${TARGET_SDK}/android.jar"
NDK_BIN="${ANDROID_NDK_HOME%/}/toolchains/llvm/prebuilt/linux-x86_64/bin"
for needed in "${NDK_BIN}/${TARGET}${MIN_SDK}-clang" "${BUILD_TOOLS}aapt2" "${BUILD_TOOLS}d8" "${ANDROID_JAR}"; do
if [ ! -e "${needed}" ]; then
echo "FEHLER: ${needed} nicht gefunden (Android-SDK/NDK, siehe Kopf des Skripts)."
exit 1
fi
done
if ! command -v javac >/dev/null 2>&1; then
echo "FEHLER: javac nicht gefunden (JDK 17 oder neuer)."
exit 1
fi
if command -v rustup >/dev/null 2>&1; then
rustup target add "${TARGET}"
fi
echo "=== [1/4] Baue libink_notes.so (${TARGET}) ==="
export CC_aarch64_linux_android="${NDK_BIN}/${TARGET}${MIN_SDK}-clang"
export AR_aarch64_linux_android="${NDK_BIN}/llvm-ar"
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="${NDK_BIN}/${TARGET}${MIN_SDK}-clang"
cargo rustc --release --lib --crate-type cdylib --target "${TARGET}"
LIB="${CARGO_TARGET_DIR:-target}/${TARGET}/release/libink_notes.so"
echo "=== [2/4] Ressourcen und Manifest ==="
WORK="target/android-apk"
rm -rf "${WORK}"
mkdir -p "${WORK}/res/mipmap-xxxhdpi" "${WORK}/compiled" "${WORK}/apk/lib/${ABI}" "${WORK}/classes"
cp assets/ink-notes.png "${WORK}/res/mipmap-xxxhdpi/ic_launcher.png"
sed -e "s/@VERSION_CODE@/${VERSION_CODE}/" -e "s/@VERSION@/${VERSION}/" \
packaging/android/AndroidManifest.xml >"${WORK}/AndroidManifest.xml"
"${BUILD_TOOLS}aapt2" compile --dir "${WORK}/res" -o "${WORK}/compiled/res.zip"
"${BUILD_TOOLS}aapt2" link -o "${WORK}/unaligned.apk" -I "${ANDROID_JAR}" \
--manifest "${WORK}/AndroidManifest.xml" \
--min-sdk-version "${MIN_SDK}" --target-sdk-version "${TARGET_SDK}" \
"${WORK}/compiled/res.zip"
echo "=== [3/4] Activity und native Bibliothek hinzufügen ==="
javac -source 8 -target 8 -nowarn -Xlint:-options -classpath "${ANDROID_JAR}" \
-d "${WORK}/classes" $(find packaging/android/java -name '*.java')
"${BUILD_TOOLS}d8" --min-api "${MIN_SDK}" --lib "${ANDROID_JAR}" --output "${WORK}/apk" \
$(find "${WORK}/classes" -name '*.class')
cp "${LIB}" "${WORK}/apk/lib/${ABI}/"
(cd "${WORK}/apk" && zip -q -r ../unaligned.apk classes.dex lib)
"${BUILD_TOOLS}zipalign" -f -p 4 "${WORK}/unaligned.apk" "${WORK}/aligned.apk"
echo "=== [4/4] Signieren ==="
if [ -n "${ANDROID_KEYSTORE:-}" ] && [ -n "${ANDROID_KEYSTORE_PASS:-}" ] && [ -n "${ANDROID_KEY_ALIAS:-}" ]; then
KEYSTORE="${ANDROID_KEYSTORE}"
KEY_ALIAS="${ANDROID_KEY_ALIAS}"
export KS_PASS="${ANDROID_KEYSTORE_PASS}"
else
KEYSTORE="${HOME}/.android/debug.keystore"
KEY_ALIAS="androiddebugkey"
export KS_PASS="android"
if [ ! -f "${KEYSTORE}" ]; then
mkdir -p "$(dirname "${KEYSTORE}")"
keytool -genkeypair -keystore "${KEYSTORE}" -storepass android -keypass android \
-alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=Android Debug,O=Android,C=US"
fi
echo "Hinweis: Signiert mit dem Debug-Schlüssel ${KEYSTORE} (nur zum Testen)."
fi
"${BUILD_TOOLS}apksigner" sign --ks "${KEYSTORE}" --ks-key-alias "${KEY_ALIAS}" \
--ks-pass env:KS_PASS --key-pass env:KS_PASS \
--out "target/${APK_NAME}" "${WORK}/aligned.apk"
"${BUILD_TOOLS}apksigner" verify "target/${APK_NAME}"
ls -lh "target/${APK_NAME}"
+5 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# ==============================================================================
# Veröffentlicht ein Release auf dem Gitea-Server (gitea.pansi.eu)
# Lädt .deb, .AppImage, .exe, SHA256SUMS.txt und SHA256SUMS.txt.minisig idempotent als Release-Assets hoch.
# Lädt .deb, .AppImage, .exe, .apk (falls gebaut), SHA256SUMS.txt und SHA256SUMS.txt.minisig idempotent als Release-Assets hoch.
# ==============================================================================
set -euo pipefail
@@ -33,6 +33,7 @@ RELEASE_TITLE="ink-notes ${TAG_NAME}"
DEB_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_amd64.deb"
APPIMAGE_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_x86_64.AppImage"
EXE_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_x86_64.exe"
APK_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_arm64.apk"
CHECKSUMS_FILE="${ROOT_DIR}/target/SHA256SUMS.txt"
SIGNATURE_FILE="${CHECKSUMS_FILE}.minisig"
TEMPLATE_FILE="${ROOT_DIR}/packaging/release-notes.md.tmpl"
@@ -238,6 +239,9 @@ except Exception:
upload_asset "${DEB_FILE}"
upload_asset "${APPIMAGE_FILE}"
upload_asset "${EXE_FILE}"
if [ -f "${APK_FILE}" ]; then
upload_asset "${APK_FILE}"
fi
upload_asset "${CHECKSUMS_FILE}"
upload_asset "${SIGNATURE_FILE}"
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# ==============================================================================
# Installs the Android SDK parts that scripts/package-android.sh needs, in fixed versions
# (Issue #45): command-line tools, platform 34, build-tools 34.0.0 and NDK 28.2.
# Used by the CI and packaging/android/Containerfile; works for a local SDK as well.
#
# ANDROID_HOME target folder (default: ~/android-sdk)
#
# Needs curl, unzip and a JDK 17 or newer (sdkmanager is a Java program).
# ==============================================================================
set -euo pipefail
ANDROID_HOME="${ANDROID_HOME:-${HOME}/android-sdk}"
CMDLINE_TOOLS_ZIP="commandlinetools-linux-11076708_latest.zip"
CMDLINE_TOOLS_SHA256="2d2d50857e4eb553af5a6dc3ad507a17adf43d115264b1afc116f95c92e5e258"
PACKAGES=(
"platforms;android-34"
"build-tools;34.0.0"
"ndk;28.2.13676358"
)
SDKMANAGER="${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager"
if [ ! -x "${SDKMANAGER}" ]; then
echo "=== Lade Android Command-line Tools ==="
TMP="$(mktemp -d)"
trap 'rm -rf "${TMP}"' EXIT
curl -sSfL -o "${TMP}/${CMDLINE_TOOLS_ZIP}" \
"https://dl.google.com/android/repository/${CMDLINE_TOOLS_ZIP}"
echo "${CMDLINE_TOOLS_SHA256} ${TMP}/${CMDLINE_TOOLS_ZIP}" | sha256sum -c -
unzip -q "${TMP}/${CMDLINE_TOOLS_ZIP}" -d "${TMP}"
mkdir -p "${ANDROID_HOME}/cmdline-tools"
rm -rf "${ANDROID_HOME}/cmdline-tools/latest"
mv "${TMP}/cmdline-tools" "${ANDROID_HOME}/cmdline-tools/latest"
fi
echo "=== Installiere ${PACKAGES[*]} nach ${ANDROID_HOME} ==="
# sdkmanager ends with an error when `yes` is cut off by a closed pipe; that's fine
(yes || true) | "${SDKMANAGER}" --sdk_root="${ANDROID_HOME}" --licenses >/dev/null
"${SDKMANAGER}" --sdk_root="${ANDROID_HOME}" --install "${PACKAGES[@]}" >/dev/null
echo "Android-SDK bereit: ${ANDROID_HOME}"
+210
View File
@@ -0,0 +1,210 @@
//! Android: calls into the app's Java activity and what it reports back (Issue #42).
//!
//! `packaging/android/java/eu/pansi/inknotes/InkActivity.java` extends NativeActivity with
//! text input through an input connection (so that keyboards can send any character) and the
//! system file dialog. Rust calls its methods through JNI; it calls the `native*` functions
//! below, possibly on another thread, so they only queue messages and wake the event loop.
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use jni::objects::{JObject, JString, JValue};
use jni::sys::jint;
use jni::JNIEnv;
use crate::file_picker::PickerKind;
use crate::ui::UiAction;
enum Message {
Text(String),
Delete { before: u32, after: u32 },
Picked(Option<PathBuf>),
}
static MESSAGES: Mutex<Vec<Message>> = Mutex::new(Vec::new());
/// What the open file dialog is for
static PICKING: Mutex<Option<PickerKind>> = Mutex::new(None);
/// Window edges covered by system bars or cutouts (left, top, right, bottom; pixels)
static INSETS: Mutex<[f32; 4]> = Mutex::new([0.0; 4]);
/// The insets changed since the last frame
static INSETS_CHANGED: AtomicBool = AtomicBool::new(false);
fn wake() {
if let Some(app) = crate::platform::android_app() {
app.create_waker().wake();
}
}
fn push(message: Message) {
MESSAGES
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(message);
wake();
}
#[no_mangle]
pub extern "system" fn Java_eu_pansi_inknotes_InkActivity_nativeCommitText(
mut env: JNIEnv,
_class: JObject,
text: JString,
) {
if let Ok(text) = env.get_string(&text) {
push(Message::Text(text.into()));
}
}
#[no_mangle]
pub extern "system" fn Java_eu_pansi_inknotes_InkActivity_nativeDeleteSurroundingText(
_env: JNIEnv,
_class: JObject,
before: jint,
after: jint,
) {
push(Message::Delete {
before: before.max(0) as u32,
after: after.max(0) as u32,
});
}
#[no_mangle]
pub extern "system" fn Java_eu_pansi_inknotes_InkActivity_nativeFilePicked(
mut env: JNIEnv,
_class: JObject,
path: JString,
) {
let path = if path.is_null() {
None
} else {
env.get_string(&path)
.ok()
.map(|p| PathBuf::from(String::from(p)))
};
push(Message::Picked(path));
}
#[no_mangle]
pub extern "system" fn Java_eu_pansi_inknotes_InkActivity_nativeSetInsets(
_env: JNIEnv,
_class: JObject,
left: jint,
top: jint,
right: jint,
bottom: jint,
) {
let insets = [left, top, right, bottom].map(|v| v.max(0) as f32);
*INSETS.lock().unwrap_or_else(|e| e.into_inner()) = insets;
INSETS_CHANGED.store(true, Ordering::Relaxed);
wake();
}
/// Window edges covered by the status bar, the navigation bar or display cutouts (left, top,
/// right, bottom; physical pixels)
pub fn insets() -> [f32; 4] {
*INSETS.lock().unwrap_or_else(|e| e.into_inner())
}
/// Is a message waiting or did the insets change (then the app draws a new frame)?
pub fn has_messages() -> bool {
INSETS_CHANGED.load(Ordering::Relaxed)
|| !MESSAGES
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_empty()
}
/// Typed text and deletions as egui input, and the action for a picked file.
pub fn take_messages() -> (Vec<egui::Event>, Vec<UiAction>) {
INSETS_CHANGED.store(false, Ordering::Relaxed);
let messages = std::mem::take(&mut *MESSAGES.lock().unwrap_or_else(|e| e.into_inner()));
let mut events = Vec::new();
let mut actions = Vec::new();
for message in messages {
match message {
Message::Text(text) => events.extend(crate::osk::committed_text_events(&text)),
Message::Delete { before, after } => {
events.extend(crate::osk::deletion_key_events(before, after))
}
Message::Picked(path) => {
let kind = PICKING.lock().unwrap_or_else(|e| e.into_inner()).take();
match (kind, path) {
(Some(PickerKind::Image), Some(path)) => {
actions.push(UiAction::InsertImageFile(path))
}
(Some(PickerKind::Import), Some(path)) => {
actions.push(UiAction::ImportFile(path))
}
_ => {}
}
}
}
}
(events, actions)
}
/// Runs `f` with the JNI environment and the activity; local references it creates are
/// freed afterwards.
fn with_activity(
f: impl FnOnce(&mut JNIEnv, &JObject) -> jni::errors::Result<()>,
) -> Result<(), String> {
let app = crate::platform::android_app().ok_or("no activity")?;
// Safety: pointers to the VM and the activity (a global reference) that live as long as
// the app
let vm = unsafe { jni::JavaVM::from_raw(app.vm_as_ptr().cast()) }.map_err(|e| e.to_string())?;
let mut env = vm
.attach_current_thread_permanently()
.map_err(|e| e.to_string())?;
let activity = unsafe { JObject::from_raw(app.activity_as_ptr().cast()) };
let result = env.with_local_frame(16, |env| f(env, &activity));
if env.exception_check().unwrap_or(false) {
let _ = env.exception_describe();
let _ = env.exception_clear();
}
result.map_err(|e| e.to_string())
}
pub fn show_keyboard(show: bool) {
let result = with_activity(|env, activity| {
env.call_method(
activity,
"showKeyboard",
"(Z)V",
&[JValue::Bool(show.into())],
)
.map(|_| ())
});
if let Err(err) = result {
log::warn!("showKeyboard: {err}");
}
}
/// Opens the system file dialog instead of the app's own; false if that is not possible
/// (e.g. for choosing a folder).
pub fn pick_file(kind: PickerKind) -> bool {
let mime_type = match kind {
PickerKind::Image => "image/*",
// Xournal++, Rnote and Saber files have no MIME type of their own
PickerKind::Import => "*/*",
PickerKind::Folder => return false,
};
*PICKING.lock().unwrap_or_else(|e| e.into_inner()) = Some(kind);
let result = with_activity(|env, activity| {
let mime = env.new_string(mime_type)?;
env.call_method(
activity,
"pickFile",
"(Ljava/lang/String;)V",
&[JValue::Object(&mime)],
)
.map(|_| ())
});
match result {
Ok(()) => true,
Err(err) => {
log::warn!("pickFile: {err}");
*PICKING.lock().unwrap_or_else(|e| e.into_inner()) = None;
false
}
}
}
+2001 -753
View File
File diff suppressed because it is too large. Load diff
+43 -5
View File
@@ -165,7 +165,10 @@ struct NeighborPage {
}
pub struct CanvasState {
pub surface: wgpu::Surface<'static>,
/// Drawing surface of the window; `None` while the app is in the background on Android
/// (the native window only exists between `Resumed` and `Suspended`)
surface: Option<wgpu::Surface<'static>>,
instance: wgpu::Instance,
/// Verwendeter Grafikadapter (für erneute Abfrage der Surface-Fähigkeiten bei Größenänderung)
adapter: wgpu::Adapter,
/// Multisampling der Zeichenfläche (1 = aus) und das zugehörige Zwischenbild
@@ -591,7 +594,8 @@ impl CanvasState {
let egui_renderer = egui_wgpu::Renderer::new(&device, render_format(&config), None, 1);
Ok(Self {
surface,
surface: Some(surface),
instance,
adapter,
msaa_samples,
msaa_view,
@@ -727,14 +731,42 @@ impl CanvasState {
self.update_uniforms();
}
/// Releases the drawing surface: on Android the native window goes away when the app
/// is sent to the background (`Event::Suspended`). Drawing is skipped until [`Self::resume`].
pub fn suspend(&mut self) {
self.surface = None;
}
/// Creates the drawing surface again for the window (`Event::Resumed` on Android).
pub fn resume(&mut self, window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
if self.surface.is_some() {
return Ok(());
}
let surface = self.instance.create_surface(window.clone())?;
if !surface
.get_capabilities(&self.adapter)
.formats
.contains(&self.config.format)
{
return Err("surface format changed".into());
}
self.surface = Some(surface);
// Configures the new surface (the window may have changed size meanwhile)
self.resize(window.inner_size());
Ok(())
}
/// Passt die Surface und Projektionsmatrizen an eine neue Fenstergröße an.
pub fn resize(&mut self, new_size: PhysicalSize<u32>) {
if new_size.width > 0 && new_size.height > 0 {
self.size = new_size;
self.config.width = new_size.width;
self.config.height = new_size.height;
let Some(surface) = &self.surface else {
return;
};
// Fähigkeiten neu abfragen: Ein nicht (mehr) angebotener Modus führt in wgpu zum Absturz
let caps = self.surface.get_capabilities(&self.adapter);
let caps = surface.get_capabilities(&self.adapter);
if !caps.present_modes.contains(&self.config.present_mode) {
match choose_present_mode(&caps.present_modes) {
Some(mode) => self.config.present_mode = mode,
@@ -750,7 +782,7 @@ impl CanvasState {
}
}
}
self.surface.configure(&self.device, &self.config);
surface.configure(&self.device, &self.config);
self.msaa_view = create_msaa_view(&self.device, &self.config, self.msaa_samples);
self.stencil_view = create_stencil_view(&self.device, &self.config, self.msaa_samples);
self.recalculate_viewport();
@@ -1386,7 +1418,13 @@ impl CanvasState {
.update_texture(&self.device, &self.queue, *id, image_delta);
}
let output = self.surface.get_current_texture()?;
let Some(surface) = &self.surface else {
for id in &textures_delta.free {
self.egui_renderer.free_texture(id);
}
return Ok(());
};
let output = surface.get_current_texture()?;
let view = output.texture.create_view(&wgpu::TextureViewDescriptor {
format: Some(render_format(&self.config)),
..Default::default()
+1945
View File
File diff suppressed because it is too large. Load diff
+23 -4
View File
@@ -19,7 +19,21 @@ use crate::storage::{self, NotebookManifest, OpenMarker, PageData};
/// Time between two checks.
pub const INTERVAL: Duration = Duration::from_secs(3);
type Fingerprint = (Option<SystemTime>, u64);
/// Modification time, size and, for small files, a hash of the content.
type Fingerprint = (Option<SystemTime>, u64, Option<u64>);
/// Files up to this size are also compared by content: file times are only as fine as the
/// kernel clock tick, so a file rewritten right after a check with the same length (e.g. a
/// manifest with one page replaced by another) would otherwise look unchanged.
const HASHED_FILE_SIZE: u64 = 64 * 1024;
fn content_hash(path: &Path) -> Option<u64> {
use std::hash::{Hash, Hasher};
let bytes = fs::read(path).ok()?;
let mut hasher = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut hasher);
Some(hasher.finish())
}
/// What a check found and took over.
#[derive(Debug, Default, Clone, PartialEq)]
@@ -61,14 +75,19 @@ fn scan(dir: &Path) -> HashMap<String, Fingerprint> {
return None;
}
let meta = entry.metadata().ok()?;
meta.is_file()
.then(|| (name, (meta.modified().ok(), meta.len())))
if !meta.is_file() {
return None;
}
let hash = (meta.len() <= HASHED_FILE_SIZE)
.then(|| content_hash(&entry.path()))
.flatten();
Some((name, (meta.modified().ok(), meta.len(), hash)))
})
.collect()
}
/// Page id of a regular page file (`page_<id>.json`).
fn page_file_id(name: &str) -> Option<u64> {
pub(crate) fn page_file_id(name: &str) -> Option<u64> {
name.strip_prefix("page_")?
.strip_suffix(".json")?
.parse()
+7
View File
@@ -51,6 +51,13 @@ pub fn sanitize_filename_component(title: &str) -> String {
/// `~/.config/user-dirs.dirs` (z. B. „Dokumente“), sonst `~/Documents`. Im Flatpak ist nur
/// dieser Ordner beschreibbar, der Fallback allein träfe bei deutschen Systemen daneben.
pub fn default_documents_dir() -> PathBuf {
// Android: written into the app's own folder first, then copied to Downloads
// (`platform::publish_export`)
#[cfg(target_os = "android")]
if let Some(dir) = crate::platform::android_app().and_then(|app| app.external_data_path()) {
return dir.join("Documents");
}
if let Ok(dir) = std::env::var("XDG_DOCUMENTS_DIR") {
if !dir.trim().is_empty() {
return PathBuf::from(dir);
+2
View File
@@ -172,6 +172,8 @@ mod tests {
("rnote.rs", include_str!("rnote.rs")),
("saber.rs", include_str!("saber.rs")),
("xopp.rs", include_str!("xopp.rs")),
("nextcloud.rs", include_str!("nextcloud.rs")),
("cloud_sync.rs", include_str!("cloud_sync.rs")),
];
/// Alle Texte in `t!("…")` und `tn!("…")` eines Quelltexts, auch über mehrere Zeilen
+41
View File
@@ -13,6 +13,7 @@ pub const EN: &[(&str, &str)] = &[
("1 Objekt | [R] Drehen [Entf] Löschen", "1 object | [R] Rotate [Del] Delete"),
("Abbrechen", "Cancel"),
("Abfrage fehlgeschlagen:", "Request failed:"),
("Abgleich beenden und das App-Passwort widerrufen; die Notizbücher bleiben auf diesem Gerät und auf dem Server", "Stop syncing and revoke the app password; the notebooks stay on this device and on the server"),
("Achsenkreuz", "Axis cross"),
("Achteck (Oktagon)", "Octagon"),
("Aktuelle Version:", "Current version:"),
@@ -20,6 +21,9 @@ pub const EN: &[(&str, &str)] = &[
("Alle", "All"),
("Alles gespeichert", "All saved"),
("Als leere Seite bearbeiten (Schreibschutz aufheben)", "Edit as blank page (remove write protection)"),
("Anmeldung abgelaufen – bitte erneut verbinden", "Sign-in expired – please connect again"),
("Anmeldung abgelehnt – bitte neu verbinden", "Sign-in rejected – please connect again"),
("Anmeldung im Browser; die App bekommt ein eigenes App-Passwort, das sich in Nextcloud jederzeit widerrufen lässt", "Sign in in the browser; the app gets an app password of its own, which can be revoked in Nextcloud at any time"),
("AppImage (Portabel)", "AppImage (portable)"),
("Asset-Name darf nicht leer sein.", "Asset name must not be empty."),
("Auch per Strg+V aus der Zwischenablage oder durch Ziehen einer Bilddatei ins Fenster", "Also with Ctrl+V from the clipboard or by dragging an image file into the window"),
@@ -44,6 +48,8 @@ pub const EN: &[(&str, &str)] = &[
("Bild mit transparentem Hintergrund, nur Inhalt", "Image with transparent background, content only"),
("Bild nicht eingefügt: {err}", "Image not inserted: {err}"),
("Bild, Text oder kopierte Objekte aus der Zwischenablage hier einfügen", "Paste an image, text or copied objects from the clipboard here"),
("Bitte die Adresse des Nextcloud-Servers eingeben", "Please enter the address of the Nextcloud server"),
("Bitte im Browser anmelden und den Zugriff erlauben …", "Please sign in in the browser and grant access …"),
("Blanko", "Blank"),
("Blau", "Blue"),
("Code", "Code"),
@@ -56,15 +62,34 @@ pub const EN: &[(&str, &str)] = &[
("Den Ordner gibt es nicht mehr", "The folder no longer exists"),
("Den Ordner „{name}“ gibt es hier schon", "A folder “{name}” already exists here"),
("Den Zielordner gibt es nicht mehr", "The target folder no longer exists"),
("Der Ordner „ink-notes“ auf dem Server fehlt. Zur Sicherheit wird nichts gelöscht – zum erneuten Hochladen die Verbindung trennen und neu verbinden.", "The folder “ink-notes” is missing on the server. To be safe, nothing is deleted – to upload again, disconnect and connect again."),
("Der Ordnername ist leer", "The folder name is empty"),
("Der Ordnername ist zu lang (höchstens {max} Zeichen)", "The folder name is too long (at most {max} characters)"),
("Der Server leitet nach {to} weiter – bitte diese Adresse verwenden", "The server redirects to {to} – please use that address"),
("Der Speicherort {dir} fehlt. Zur Sicherheit wird nichts gelöscht.", "The storage location {dir} is missing. To be safe, nothing is deleted."),
("Der übergeordnete Ordner existiert nicht mehr", "The parent folder no longer exists"),
("DICKEN-PRESETS", "WIDTH PRESETS"),
("Die Adresse muss mit https:// beginnen", "The address must start with https://"),
("Die beschädigte Originaldatei wurde in Quarantäne gesichert:", "The damaged original file was saved to quarantine:"),
("Die Datei ist kein lesbares PDF", "The file is not a readable PDF"),
("Die einzige Seite eines Notizbuchs kann nicht gelöscht werden", "The only page of a notebook cannot be deleted"),
("Die Flatpak-Version wird über die Software-Verwaltung aktualisiert.", "The Flatpak version is updated through the software manager."),
("Die heruntergeladene Datei existiert nicht mehr.", "The downloaded file no longer exists."),
("Inzwischen geändert", "Changed meanwhile"),
("Kein Datenordner", "No data folder"),
("NEXTCLOUD", "NEXTCLOUD"),
("Nextcloud getrennt – die Notizbücher bleiben auf diesem Gerät", "Nextcloud disconnected – the notebooks stay on this device"),
("Nicht gefunden", "Not found"),
("Server nicht erreichbar: {msg}", "Server not reachable: {msg}"),
("Server-Fehler {code}", "Server error {code}"),
("Sofort mit dem Server abgleichen", "Sync with the server right away"),
("Trennen", "Disconnect"),
("Unerwartete Antwort des Servers", "Unexpected answer from the server"),
("Ungültige Adresse: {e}", "Invalid address: {e}"),
("Unter dieser Adresse antwortet kein Nextcloud-Server", "No Nextcloud server answers at this address"),
("Vertrauen", "Trust"),
("Zertifikat nicht vertrauenswürdig (SHA-256 {fingerprint})", "Certificate not trusted (SHA-256 {fingerprint})"),
("Zugang nicht gespeichert: {err}", "Sign-in not saved: {err}"),
("die oberste Ebene", "the top level"),
("Die Zwischenablage ist leer", "The clipboard is empty"),
("Diese Version wird über die Software-Verwaltung aktualisiert (z. B. GNOME Software oder „flatpak update“).", "This version is updated through the software manager (e.g. GNOME Software or “flatpak update”)."),
@@ -107,6 +132,7 @@ pub const EN: &[(&str, &str)] = &[
("Fehler beim Öffnen: {err}", "Error while opening: {err}"),
("Fehler: {err}", "Error: {err}"),
("Fehlermeldung: {message}", "Error message: {message}"),
("Formeln nach „=“ ausrechnen", "Compute formulas after “=”"),
("Formen (Shape)", "Shapes"),
("Formen: Striche werden zu Linien, Rechtecken, Kreisen … (S/F)", "Shapes: strokes become lines, rectangles, circles … (S/F)"),
("Fünfeck (Pentagon)", "Pentagon"),
@@ -123,6 +149,7 @@ pub const EN: &[(&str, &str)] = &[
("Graphit-Grau", "Graphite gray"),
("Größere Schrift (jetzt {size} pt)", "Larger font (now {size} pt)"),
("Grün", "Green"),
("gerade eben", "just now"),
("hart", "hard"),
("Hell", "Light"),
("Hellgrün", "Light green"),
@@ -186,6 +213,7 @@ pub const EN: &[(&str, &str)] = &[
("Mindestdruck", "Minimum pressure"),
("Mit dem Finger zeichnen", "Draw with finger"),
("Mittel", "Medium"),
("Nach einem handgeschriebenen „=“ erscheint das Ergebnis daneben, z. B. 2⁴ = 16. Antippen mit dem Stift oder Klicken fügt es als Text ein.\nGelesen werden Ziffern, + − · : /, Klammern, Hochzahlen, Brüche und x, y, a, b.", "After a handwritten “=” the result appears next to it, e.g. 2⁴ = 16. Tapping it with the pen or clicking inserts it as text.\nDigits, + − · : /, parentheses, exponents, fractions and x, y, a, b are read."),
("Name …", "Name …"),
("Neon-Gelb", "Neon yellow"),
("Netzwerk-Transportfehler: {t}", "Network transport error: {t}"),
@@ -364,6 +392,8 @@ pub const EN: &[(&str, &str)] = &[
("Verzeichnis '{path}' enthält kein manifest.json", "Folder '{path}' contains no manifest.json"),
("Verzeichnis '{path}' existiert nicht", "Folder '{path}' does not exist"),
("Vorherige Seite (Bild auf)", "Previous page (Page Up)"),
("vor {hours} h", "{hours} h ago"),
("vor {minutes} min", "{minutes} min ago"),
("weich", "soft"),
("Weich: schon leichter Druck ergibt kräftige Linien.\nHart: erst fester Druck ergibt kräftige Linien.", "Soft: even light pressure gives bold lines.\nHard: only firm pressure gives bold lines."),
("Weitere Funktionen – Update verfügbar", "More – update available"),
@@ -383,6 +413,7 @@ pub const EN: &[(&str, &str)] = &[
("Zugriffsfehler (I/O). Die Datei existiert, kann aber nicht gelesen werden.\nDie Seite bleibt schreibgeschützt, um ein Überschreiben zu verhindern.", "Access error (I/O). The file exists but cannot be read.\nThe page stays read-only to prevent overwriting it."),
("Zurück zu Seiten geht nur, solange alles im Startausschnitt liegt", "Back to pages only works while everything lies in the starting area"),
("Zurücksetzen (Strg+0)", "Reset (Ctrl+0)"),
("z. B. cloud.example.org", "e.g. cloud.example.org"),
("{count} Objekt(e) eingefügt", "{count} object(s) pasted"),
("{count} Objekt(e) gelöscht", "{count} object(s) deleted"),
("{count} Objekt(e) kopiert – auch als Bild für andere Programme", "{count} object(s) copied – also as an image for other apps"),
@@ -403,21 +434,28 @@ pub const EN: &[(&str, &str)] = &[
("„{title}“ verschieben nach:", "Move “{title}” to:"),
("↩ Rückgängig", "↩ Undo"),
("⏳ Update lädt … {percent} %", "⏳ Downloading update … {percent} %"),
("⏳ Verbinde …", "⏳ Connecting …"),
("⏷ {mm} mm Platz", "⏷ {mm} mm space"),
("♾ Endlos", "♾ Endless"),
("⚙ Erweiterte Server-Einstellungen", "⚙ Advanced server settings"),
("⚠ Beschädigt", "⚠ Damaged"),
("⚠ Das Zertifikat von {server} ist von keiner bekannten Stelle signiert. Nur vertrauen, wenn dieser Fingerabdruck mit dem des Servers übereinstimmt:", "⚠ The certificate of {server} isn't signed by a known authority. Only trust it if this fingerprint matches the server's:"),
("⚠ Datenintegritäts-Warnung", "⚠ Data integrity warning"),
("⚠ In anderer Instanz geöffnet (Schreibschutz)", "⚠ Opened in another instance (read-only)"),
("⚠ Ohne https ist das App-Passwort im Netz lesbar – nur im eigenen Heimnetz verwenden", "⚠ Without https the app password can be read on the network – only use this in your own home network"),
("⚠ Ungespeicherte Änderungen", "⚠ Unsaved changes"),
("⚠ Unverschlüsselt (http)", "⚠ Unencrypted (http)"),
("⚠\u{fe0f} {name} (Beschädigt - kein Manifest)", "⚠\u{fe0f} {name} (damaged - no manifest)"),
("⚠\u{fe0f} {name} (Beschädigt)", "⚠\u{fe0f} {name} (damaged)"),
("⚪ Offline – neuer Versuch in Kürze ({msg})", "⚪ Offline – trying again shortly ({msg})"),
("⚫ Aktiv", "⚫ Active"),
("✔ Synchronisiert {when}", "✔ Synced {when}"),
("✔ Update bereit", "✔ Update ready"),
("➕ Neue Seite einfügen", "➕ Insert new page"),
("➕ Notizbuch", "➕ Notebook"),
("⟲ Gelöschte Seite {number} wiederherstellen", "⟲ Restore deleted page {number}"),
("⬇ Downloads", "⬇ Downloads"),
("🌐 Browser öffnen", "🌐 Open browser"),
("🌐 Release auf Gitea öffnen", "🌐 Open release on Gitea"),
("🏠 Persönlicher Ordner", "🏠 Home folder"),
("💾 Einstellungen speichern", "💾 Save settings"),
@@ -433,10 +471,13 @@ pub const EN: &[(&str, &str)] = &[
("📚 Oberste Ebene", "📚 Top level"),
("📜 Durchgehend", "📜 Continuous"),
("📦 Paket mit Software-Center öffnen / installieren", "📦 Open / install package with the software center"),
("🔄 Jetzt", "🔄 Now"),
("🔄 Suche nach Updates …", "🔄 Checking for updates …"),
("🔄 Synchronisiere …", "🔄 Syncing …"),
("🔄 Updates & Versionen", "🔄 Updates & versions"),
("🔄 Backup", "🔄 Backup"),
("🔄 Nach Updates suchen", "🔄 Check for updates"),
("🔗 Verbinden …", "🔗 Connect …"),
("🖥 Schreibtisch", "🖥 Desktop"),
("🖼 Bild aus Datei …", "🖼 Image from file …"),
("🖼 Bild einfügen … (Strg+I)", "🖼 Insert image … (Ctrl+I)"),
+12 -3
View File
@@ -1,10 +1,13 @@
//! ink-notes: Latenzoptimierte Inking-App für Linux.
#[cfg(target_os = "android")]
pub mod android_bridge;
pub mod app;
pub mod assets;
pub mod autosave;
pub mod background;
pub mod canvas;
pub mod cloud_sync;
pub mod disk_watch;
pub mod document;
pub mod export;
@@ -16,12 +19,17 @@ pub mod i18n;
pub mod image_layer;
pub mod import;
pub mod library;
pub mod math;
pub mod math_assist;
pub mod math_ink;
pub mod nextcloud;
pub mod osk;
pub mod page_export;
pub mod paths;
pub mod pdf_doc;
pub mod pdf_merge;
pub mod pdf_writer;
pub mod pen;
pub mod platform;
pub mod rnote;
pub mod ruler;
@@ -38,9 +46,7 @@ pub mod xopp;
/// Entry point on Android (Issue #42), called by the native activity.
///
/// Builds with `cargo rustc --lib --crate-type cdylib --target aarch64-linux-android`. Not
/// usable yet: the window surface still has to be created on `Resumed` and dropped on
/// `Suspended`.
/// `scripts/package-android.sh` builds the library and packs it into an APK.
#[cfg(target_os = "android")]
#[no_mangle]
fn android_main(app: winit::platform::android::activity::AndroidApp) {
@@ -51,6 +57,9 @@ fn android_main(app: winit::platform::android::activity::AndroidApp) {
.with_max_level(log::LevelFilter::Info)
.with_tag("ink-notes"),
);
// Panics would otherwise only go to stderr, which Android discards
std::panic::set_hook(Box::new(|info| log::error!("{info}")));
platform::set_android_app(app.clone());
if let Some(dir) = app.internal_data_path() {
paths::set_app_dir(dir);
}
+757
View File
@@ -0,0 +1,757 @@
//! Exact evaluation of handwritten math (Issue #46, stage 1).
//!
//! Takes the text of a recognised expression such as `2^4`, `2,5 · 3` or `(x + 3)²`
//! (optionally ending with `=`) and returns its result as text: `16`, `7,5` and
//! `x² + 6x + 9`. Numbers are exact fractions, so `1 : 3 · 3` gives `1`, and expressions
//! with variables are expanded into polynomials.
//!
//! The input is lenient towards how math is written by hand: decimal comma or point,
//! `·`, `×`, `*` and `:`, `÷`, `/`, superscript exponents, Unicode minus signs and implicit
//! multiplication (`2x`, `3(x + 1)`, `(a + b)(a − b)`).
use std::cmp::Ordering;
use std::collections::BTreeMap;
use std::fmt::Write as _;
/// Largest exponent for a polynomial that is not a plain number
const MAX_POLYNOMIAL_EXPONENT: u32 = 32;
/// Largest exponent for a plain number
const MAX_NUMBER_EXPONENT: u32 = 1000;
/// Results with more terms are not shown (they would not fit next to the `=` anyway)
const MAX_TERMS: usize = 64;
/// Terminating decimals with more digits after the separator are shown as fractions
const MAX_DECIMALS: u32 = 12;
/// Computes the result of `expression`, written for display after its `=` sign.
///
/// `decimal_comma` selects the decimal separator of the result. Returns `None` if the
/// expression cannot be read or computed (e.g. division by zero, a variable in a
/// denominator or overflow), and also if there is nothing to compute, i.e. the result would
/// just repeat the input (`5`, `x`, `2x`).
pub fn solve(expression: &str, decimal_comma: bool) -> Option<String> {
let tokens = tokenize(expression)?;
let ast = Parser::new(&tokens).parse()?;
if matches!(ast, Expr::Number(_) | Expr::Variable(_)) {
return None;
}
let result = ast.eval()?;
if result.terms.len() > MAX_TERMS {
return None;
}
let text = result.format(decimal_comma);
let compact = |s: &str| -> String {
s.chars()
.filter(|c| !c.is_whitespace())
.map(|c| if c == '−' { '-' } else { c })
.collect()
};
if compact(&text) == compact(expression.trim().trim_end_matches('=')) {
return None;
}
Some(text)
}
/// `expression` written neatly for showing it, e.g. `2⁴` for `2^(4)`, `(x + 1)/2` for
/// `((x+1)/(2))` or `2,5 · 3` for `2,5*3`. `None` if it cannot be read.
pub fn display(expression: &str, decimal_comma: bool) -> Option<String> {
let tokens = tokenize(expression)?;
let ast = Parser::new(&tokens).parse()?;
Some(ast.display(decimal_comma))
}
// --- Rational numbers ---------------------------------------------------------------------
/// Exact fraction `num / den` with `den > 0`, always reduced
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Rational {
num: i128,
den: i128,
}
fn gcd(mut a: i128, mut b: i128) -> i128 {
a = a.abs();
b = b.abs();
while b != 0 {
(a, b) = (b, a % b);
}
a
}
impl Rational {
const ZERO: Rational = Rational { num: 0, den: 1 };
const ONE: Rational = Rational { num: 1, den: 1 };
fn new(num: i128, den: i128) -> Option<Rational> {
// i128::MIN has no positive counterpart, which `gcd` and `neg` need
if den == 0 || num == i128::MIN || den == i128::MIN {
return None;
}
let g = gcd(num, den).max(1);
let (mut num, mut den) = (num / g, den / g);
if den < 0 {
num = num.checked_neg()?;
den = den.checked_neg()?;
}
Some(Rational { num, den })
}
fn integer(n: i128) -> Rational {
Rational { num: n, den: 1 }
}
fn is_zero(self) -> bool {
self.num == 0
}
fn add(self, other: Rational) -> Option<Rational> {
let g = gcd(self.den, other.den);
let den = (self.den / g).checked_mul(other.den)?;
let num = self
.num
.checked_mul(other.den / g)?
.checked_add(other.num.checked_mul(self.den / g)?)?;
Rational::new(num, den)
}
fn neg(self) -> Option<Rational> {
Some(Rational {
num: self.num.checked_neg()?,
den: self.den,
})
}
fn mul(self, other: Rational) -> Option<Rational> {
// Cross-reducing first keeps the intermediate products small
let g1 = gcd(self.num, other.den).max(1);
let g2 = gcd(other.num, self.den).max(1);
let num = (self.num / g1).checked_mul(other.num / g2)?;
let den = (self.den / g2).checked_mul(other.den / g1)?;
Rational::new(num, den)
}
fn recip(self) -> Option<Rational> {
Rational::new(self.den, self.num)
}
fn pow(self, exponent: i128) -> Option<Rational> {
if exponent.unsigned_abs() > MAX_NUMBER_EXPONENT as u128 {
return None;
}
let base = if exponent < 0 { self.recip()? } else { self };
let mut result = Rational::ONE;
for _ in 0..exponent.unsigned_abs() {
result = result.mul(base)?;
}
Some(result)
}
fn as_integer(self) -> Option<i128> {
(self.den == 1).then_some(self.num)
}
/// The number without its sign: as a terminating decimal if it has one with at most
/// [`MAX_DECIMALS`] digits, otherwise as a fraction `p/q`.
fn format_abs(self, decimal_comma: bool) -> String {
let num = self.num.unsigned_abs();
let den = self.den.unsigned_abs();
let whole = num / den;
let mut rest = num % den;
if rest == 0 {
return whole.to_string();
}
let mut reduced = den;
let mut decimals = 0;
for factor in [2, 5] {
while reduced.is_multiple_of(factor) {
reduced /= factor;
}
}
if reduced == 1 {
let mut digits = String::new();
while rest != 0 && decimals <= MAX_DECIMALS {
let Some(shifted) = rest.checked_mul(10) else {
break;
};
rest = shifted;
digits.push(char::from(b'0' + (rest / den) as u8));
rest %= den;
decimals += 1;
}
if rest == 0 {
let separator = if decimal_comma { ',' } else { '.' };
return format!("{whole}{separator}{digits}");
}
}
format!("{num}/{den}")
}
}
// --- Polynomials --------------------------------------------------------------------------
/// Product of variables with their exponents, e.g. `a²b` = `{a: 2, b: 1}`
type Monomial = BTreeMap<char, u32>;
/// Sum of monomials with exact coefficients; a plain number is the empty monomial
#[derive(Debug, Clone, PartialEq, Eq)]
struct Polynomial {
terms: BTreeMap<Monomial, Rational>,
}
impl Polynomial {
fn constant(value: Rational) -> Polynomial {
let mut terms = BTreeMap::new();
if !value.is_zero() {
terms.insert(Monomial::new(), value);
}
Polynomial { terms }
}
fn variable(name: char) -> Polynomial {
let mut terms = BTreeMap::new();
terms.insert(Monomial::from([(name, 1)]), Rational::ONE);
Polynomial { terms }
}
/// The value if this is a plain number
fn as_constant(&self) -> Option<Rational> {
match self.terms.len() {
0 => Some(Rational::ZERO),
1 => self.terms.get(&Monomial::new()).copied(),
_ => None,
}
}
fn add_term(&mut self, monomial: Monomial, coefficient: Rational) -> Option<()> {
let sum = match self.terms.get(&monomial) {
Some(existing) => existing.add(coefficient)?,
None => coefficient,
};
if sum.is_zero() {
self.terms.remove(&monomial);
} else {
self.terms.insert(monomial, sum);
}
Some(())
}
fn add(mut self, other: &Polynomial) -> Option<Polynomial> {
for (monomial, coefficient) in &other.terms {
self.add_term(monomial.clone(), *coefficient)?;
}
Some(self)
}
fn neg(&self) -> Option<Polynomial> {
let mut terms = BTreeMap::new();
for (monomial, coefficient) in &self.terms {
terms.insert(monomial.clone(), coefficient.neg()?);
}
Some(Polynomial { terms })
}
fn mul(&self, other: &Polynomial) -> Option<Polynomial> {
let mut product = Polynomial::constant(Rational::ZERO);
for (m1, c1) in &self.terms {
for (m2, c2) in &other.terms {
let mut monomial = m1.clone();
for (&name, &exponent) in m2 {
let e = monomial.entry(name).or_insert(0);
*e = e.checked_add(exponent)?;
}
product.add_term(monomial, c1.mul(*c2)?)?;
}
if product.terms.len() > MAX_TERMS * 4 {
return None;
}
}
Some(product)
}
fn div(&self, divisor: &Polynomial) -> Option<Polynomial> {
let factor = divisor.as_constant()?.recip()?;
self.mul(&Polynomial::constant(factor))
}
fn pow(&self, exponent: &Polynomial) -> Option<Polynomial> {
let exponent = exponent.as_constant()?.as_integer()?;
if let Some(base) = self.as_constant() {
return Some(Polynomial::constant(base.pow(exponent)?));
}
if !(0..=MAX_POLYNOMIAL_EXPONENT as i128).contains(&exponent) {
return None;
}
let mut result = Polynomial::constant(Rational::ONE);
for _ in 0..exponent {
result = result.mul(self)?;
}
Some(result)
}
/// E.g. `x² + 6x + 9`, `a² − b²`, `7,5`: highest degree first, then in alphabetical
/// order of the variables (`a² + 2ab + b²`).
fn format(&self, decimal_comma: bool) -> String {
if self.terms.is_empty() {
return "0".to_owned();
}
let degree = |m: &Monomial| m.values().sum::<u32>();
let mut terms: Vec<_> = self.terms.iter().collect();
terms.sort_by(|(a, _), (b, _)| degree(b).cmp(&degree(a)).then_with(|| graded_order(a, b)));
let mut out = String::new();
for (index, (monomial, coefficient)) in terms.into_iter().enumerate() {
let negative = coefficient.num < 0;
match (index, negative) {
(0, true) => out.push('−'),
(0, false) => {}
(_, true) => out.push_str(" − "),
(_, false) => out.push_str(" + "),
}
let magnitude = coefficient.format_abs(decimal_comma);
if monomial.is_empty() {
out.push_str(&magnitude);
continue;
}
if magnitude.contains('/') {
let _ = write!(out, "({magnitude})");
} else if magnitude != "1" {
out.push_str(&magnitude);
}
for (&name, &exponent) in monomial {
out.push(name);
if exponent > 1 {
out.push_str(&superscript(exponent));
}
}
}
out
}
}
/// Order within the same degree: the monomial with the higher power of the alphabetically
/// first variable comes first (`a²`, `ab`, `b²`).
fn graded_order(a: &Monomial, b: &Monomial) -> Ordering {
let names: std::collections::BTreeSet<char> = a.keys().chain(b.keys()).copied().collect();
for name in names {
let ea = a.get(&name).copied().unwrap_or(0);
let eb = b.get(&name).copied().unwrap_or(0);
match eb.cmp(&ea) {
Ordering::Equal => continue,
other => return other,
}
}
Ordering::Equal
}
const SUPERSCRIPT_DIGITS: [char; 10] = ['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹'];
fn superscript(n: u32) -> String {
n.to_string()
.chars()
.map(|d| SUPERSCRIPT_DIGITS[d.to_digit(10).unwrap_or(0) as usize])
.collect()
}
// --- Reading the expression ---------------------------------------------------------------
#[derive(Debug, Clone, PartialEq)]
enum Token {
Number(Rational),
Variable(char),
Plus,
Minus,
Times,
/// `/` for a fraction, `:` for a division sign (`:` and `÷`)
Divide(char),
Power,
Open,
Close,
}
fn tokenize(input: &str) -> Option<Vec<Token>> {
let input = input.trim().trim_end_matches('=').trim_end();
let chars: Vec<char> = input.chars().collect();
let mut tokens = Vec::new();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c.is_ascii_digit() {
let (value, next) = read_number(&chars, i)?;
tokens.push(Token::Number(value));
i = next;
continue;
}
if superscript_value(c).is_some() || c == '⁻' {
// A run of superscript characters is the exponent: x²³ = x^23, 2⁻¹ = 2^(-1)
let negative = c == '⁻';
let start = if negative { i + 1 } else { i };
let mut end = start;
let mut value: i128 = 0;
while let Some(digit) = chars.get(end).and_then(|&d| superscript_value(d)) {
value = value.checked_mul(10)?.checked_add(digit)?;
end += 1;
}
if end == start {
return None;
}
tokens.push(Token::Power);
if negative {
tokens.push(Token::Minus);
}
tokens.push(Token::Number(Rational::integer(value)));
i = end;
continue;
}
let token = match c {
_ if c.is_whitespace() => None,
'+' => Some(Token::Plus),
'-' | '−' | '–' => Some(Token::Minus),
'*' | '·' | '⋅' | '∙' | '×' => Some(Token::Times),
'/' => Some(Token::Divide('/')),
':' | '÷' => Some(Token::Divide(':')),
'^' => Some(Token::Power),
'(' | '[' | '{' => Some(Token::Open),
')' | ']' | '}' => Some(Token::Close),
_ if c.is_ascii_alphabetic() => Some(Token::Variable(c)),
_ => return None,
};
tokens.extend(token);
i += 1;
}
Some(tokens)
}
fn superscript_value(c: char) -> Option<i128> {
SUPERSCRIPT_DIGITS
.iter()
.position(|&d| d == c)
.map(|p| p as i128)
}
/// Reads `12`, `2,5` or `0.125` from `start`; returns the value and the index after it
fn read_number(chars: &[char], start: usize) -> Option<(Rational, usize)> {
let mut i = start;
let mut num: i128 = 0;
let mut den: i128 = 1;
let mut in_fraction = false;
while i < chars.len() {
let c = chars[i];
if let Some(digit) = c.to_digit(10) {
num = num.checked_mul(10)?.checked_add(digit as i128)?;
if in_fraction {
den = den.checked_mul(10)?;
}
} else if (c == ',' || c == '.')
&& !in_fraction
&& chars.get(i + 1).is_some_and(|d| d.is_ascii_digit())
{
in_fraction = true;
} else {
break;
}
i += 1;
}
Some((Rational::new(num, den)?, i))
}
#[derive(Debug, Clone, PartialEq)]
enum Expr {
Number(Rational),
Variable(char),
Neg(Box<Expr>),
Add(Box<Expr>, Box<Expr>),
Sub(Box<Expr>, Box<Expr>),
Mul(Box<Expr>, Box<Expr>),
/// With the sign it was written with, for [`display`]
Div(Box<Expr>, Box<Expr>, char),
Pow(Box<Expr>, Box<Expr>),
}
impl Expr {
fn eval(&self) -> Option<Polynomial> {
Some(match self {
Expr::Number(value) => Polynomial::constant(*value),
Expr::Variable(name) => Polynomial::variable(*name),
Expr::Neg(e) => e.eval()?.neg()?,
Expr::Add(a, b) => a.eval()?.add(&b.eval()?)?,
Expr::Sub(a, b) => a.eval()?.add(&b.eval()?.neg()?)?,
Expr::Mul(a, b) => a.eval()?.mul(&b.eval()?)?,
Expr::Div(a, b, _) => a.eval()?.div(&b.eval()?)?,
Expr::Pow(a, b) => a.eval()?.pow(&b.eval()?)?,
})
}
}
impl Expr {
/// Binding strength for deciding on parentheses: sums < products < signs < powers < atoms
fn precedence(&self) -> u8 {
match self {
Expr::Add(..) | Expr::Sub(..) => 1,
Expr::Mul(..) | Expr::Div(..) => 2,
Expr::Neg(_) => 3,
Expr::Pow(..) => 4,
Expr::Number(_) | Expr::Variable(_) => 5,
}
}
/// The text of this expression, in parentheses if it binds weaker than `min`
fn display_at(&self, min: u8, decimal_comma: bool) -> String {
let text = self.display(decimal_comma);
if self.precedence() < min {
format!("({text})")
} else {
text
}
}
fn display(&self, decimal_comma: bool) -> String {
let dc = decimal_comma;
match self {
Expr::Number(value) => value.format_abs(dc),
Expr::Variable(name) => name.to_string(),
Expr::Neg(e) => format!("−{}", e.display_at(3, dc)),
Expr::Add(a, b) => format!("{} + {}", a.display_at(1, dc), b.display_at(1, dc)),
Expr::Sub(a, b) => format!("{} − {}", a.display_at(1, dc), b.display_at(2, dc)),
Expr::Mul(a, b) => {
let left = a.display_at(2, dc);
let right = b.display_at(3, dc);
// 2x, 3(x + 1), x², but 2 · 3 and x · 2
let implicit = !matches!(**a, Expr::Neg(_))
&& (matches!(**b, Expr::Variable(_))
|| matches!(&**b, Expr::Pow(base, _) if matches!(**base, Expr::Variable(_)))
|| right.starts_with('('));
if implicit {
format!("{left}{right}")
} else {
format!("{left} · {right}")
}
}
Expr::Div(a, b, sign) => match sign {
'/' => format!("{}/{}", a.display_at(4, dc), b.display_at(4, dc)),
_ => format!("{} : {}", a.display_at(2, dc), b.display_at(3, dc)),
},
Expr::Pow(base, exponent) => {
let base = base.display_at(5, dc);
let integer = |e: &Expr| match e {
Expr::Number(v) => v.as_integer().map(|n| superscript(n as u32)),
_ => None,
};
let small = match &**exponent {
Expr::Neg(e) => integer(e).map(|s| format!("⁻{s}")),
e => integer(e),
};
match small {
Some(small) => format!("{base}{small}"),
None => format!("{base}^({})", exponent.display(dc)),
}
}
}
}
}
/// Recursive descent with the usual precedence: `+ −` < `· :` and implicit
/// multiplication < unary minus < `^` (right-associative, so `−2² = −4` and `2^3^2 = 2^9`).
struct Parser<'a> {
tokens: &'a [Token],
pos: usize,
}
impl<'a> Parser<'a> {
fn new(tokens: &'a [Token]) -> Self {
Parser { tokens, pos: 0 }
}
fn parse(mut self) -> Option<Expr> {
let expr = self.sum()?;
(self.pos == self.tokens.len()).then_some(expr)
}
fn peek(&self) -> Option<&Token> {
self.tokens.get(self.pos)
}
fn eat(&mut self, token: &Token) -> bool {
let found = self.peek() == Some(token);
if found {
self.pos += 1;
}
found
}
fn sum(&mut self) -> Option<Expr> {
let mut left = self.product()?;
loop {
if self.eat(&Token::Plus) {
left = Expr::Add(Box::new(left), Box::new(self.product()?));
} else if self.eat(&Token::Minus) {
left = Expr::Sub(Box::new(left), Box::new(self.product()?));
} else {
return Some(left);
}
}
}
fn product(&mut self) -> Option<Expr> {
let mut left = self.unary()?;
loop {
if self.eat(&Token::Times) {
left = Expr::Mul(Box::new(left), Box::new(self.unary()?));
} else if let Some(&Token::Divide(sign)) = self.peek() {
self.pos += 1;
left = Expr::Div(Box::new(left), Box::new(self.unary()?), sign);
} else if matches!(self.peek(), Some(Token::Variable(_) | Token::Open)) {
// Implicit multiplication: 2x, 3(x + 1), (a + b)(a − b). Two numbers in a
// row are not multiplied, they are more likely a misread.
left = Expr::Mul(Box::new(left), Box::new(self.power()?));
} else {
return Some(left);
}
}
}
fn unary(&mut self) -> Option<Expr> {
if self.eat(&Token::Minus) {
return Some(Expr::Neg(Box::new(self.unary()?)));
}
if self.eat(&Token::Plus) {
return self.unary();
}
self.power()
}
fn power(&mut self) -> Option<Expr> {
let base = self.atom()?;
if self.eat(&Token::Power) {
return Some(Expr::Pow(Box::new(base), Box::new(self.unary()?)));
}
Some(base)
}
fn atom(&mut self) -> Option<Expr> {
let token = self.peek()?.clone();
self.pos += 1;
match token {
Token::Number(value) => Some(Expr::Number(value)),
Token::Variable(name) => Some(Expr::Variable(name)),
Token::Open => {
let inner = self.sum()?;
self.eat(&Token::Close).then_some(inner)
}
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn de(expression: &str) -> Option<String> {
solve(expression, true)
}
#[test]
fn arithmetic() {
assert_eq!(de("2^4 ="), Some("16".into()));
assert_eq!(de("2⁴="), Some("16".into()));
assert_eq!(de("1 + 2 · 3"), Some("7".into()));
assert_eq!(de("(1 + 2) × 3"), Some("9".into()));
assert_eq!(de("7 − 10"), Some("−3".into()));
assert_eq!(de("−2^2"), Some("−4".into()));
assert_eq!(de("(−2)²"), Some("4".into()));
assert_eq!(de("2^3^2"), Some("512".into()));
assert_eq!(de("10 : 4"), Some("2,5".into()));
assert_eq!(de("10 ÷ 4"), Some("2,5".into()));
assert_eq!(de("3 * [2 + 1]"), Some("9".into()));
}
#[test]
fn decimals_and_fractions() {
assert_eq!(de("2,5 · 3"), Some("7,5".into()));
assert_eq!(solve("2.5 * 3", false), Some("7.5".into()));
assert_eq!(de("0,1 + 0,2"), Some("0,3".into()));
assert_eq!(de("1 : 3"), Some("1/3".into()));
assert_eq!(de("1 : 3 · 3"), Some("1".into()));
assert_eq!(de("2^-2"), Some("0,25".into()));
assert_eq!(de("2⁻¹"), Some("0,5".into()));
assert_eq!(de("1 : 8"), Some("0,125".into()));
}
#[test]
fn polynomials() {
assert_eq!(de("(x + 3)^2 ="), Some("x² + 6x + 9".into()));
assert_eq!(de("(x+3)²"), Some("x² + 6x + 9".into()));
assert_eq!(de("(a + b)²"), Some("a² + 2ab + b²".into()));
assert_eq!(de("(a + b)(a − b)"), Some("a² − b²".into()));
assert_eq!(de("(a − b)²"), Some("a² − 2ab + b²".into()));
assert_eq!(de("2x + 3x"), Some("5x".into()));
assert_eq!(de("x · x"), Some("x²".into()));
assert_eq!(de("3(x + 1)"), Some("3x + 3".into()));
assert_eq!(de("(x + 1)^3"), Some("x³ + 3x² + 3x + 1".into()));
assert_eq!(de("(2x − 1)²"), Some("4x² − 4x + 1".into()));
assert_eq!(de("x − x"), Some("0".into()));
assert_eq!(de("(x + 1) : 2"), Some("0,5x + 0,5".into()));
assert_eq!(de("x : 3"), Some("(1/3)x".into()));
assert_eq!(de("−(x + 1)"), Some("−x − 1".into()));
assert_eq!(
de("(x+1)^10").map(|s| s.starts_with("x¹⁰ + 10x⁹")),
Some(true)
);
}
#[test]
fn nothing_to_compute() {
assert_eq!(de("5"), None);
assert_eq!(de("x ="), None);
assert_eq!(de("2x"), None);
assert_eq!(de("x² + 1"), None);
assert_eq!(de("2,5"), None);
}
#[test]
fn unreadable_or_not_computable() {
assert_eq!(de(""), None);
assert_eq!(de("="), None);
assert_eq!(de("1 +"), None);
assert_eq!(de("(1 + 2"), None);
assert_eq!(de("1 + 2)"), None);
assert_eq!(de("2 3"), None);
assert_eq!(de("1 : 0"), None);
assert_eq!(de("0^-1"), None);
assert_eq!(de("1 : x"), None);
assert_eq!(de("x^y"), None);
assert_eq!(de("x^0,5"), None);
assert_eq!(de("2 ? 3"), None);
assert_eq!(de("1,2,3"), None);
}
#[test]
fn displays_expressions() {
let show = |e: &str| display(e, true).unwrap();
assert_eq!(show("2^(4)"), "2⁴");
assert_eq!(show("(x+3)^(2)"), "(x + 3)²");
assert_eq!(show("2,5·3"), "2,5 · 3");
assert_eq!(show("9:3"), "9 : 3");
assert_eq!(show("((x+1)/(2))"), "(x + 1)/2");
assert_eq!(show("((1)/(2))+((1)/(2·3))"), "1/2 + 1/(2 · 3)");
assert_eq!(show("2^(-1)"), "2⁻¹");
assert_eq!(show("2^(x+1)"), "2^(x + 1)");
assert_eq!(show("3(x+1)"), "3(x + 1)");
assert_eq!(show("2x^(2)"), "2x²");
assert_eq!(show("x·2"), "x · 2");
assert_eq!(show("(a+b)(a-b)"), "(a + b)(a − b)");
assert_eq!(show("7-(5-2)"), "7 − (5 − 2)");
assert_eq!(show("-2^(2)"), "−2²");
assert_eq!(show("(-2)^(2)"), "(−2)²");
assert_eq!(show("0.5"), "0,5");
assert!(display("1 +", true).is_none());
}
#[test]
fn overflow_and_limits() {
assert_eq!(de("10^100"), None);
assert_eq!(de("2^100000"), None);
assert_eq!(de("(x + 1)^33"), None);
assert_eq!(de("(a+b+c+d+e)^8"), None);
assert_eq!(de("99999999999999999999999999999999999999999999 + 1"), None);
assert_eq!(de("2^64"), Some("18446744073709551616".into()));
}
}
+465
View File
@@ -0,0 +1,465 @@
//! Computes handwritten math after an `=` (Issue #46, stage 3).
//!
//! When the last two strokes on a page form an `=` and the pen then rests for
//! [`TRIGGER_DELAY`], the strokes left of it are recognized ([`crate::math_ink`]) and computed
//! ([`crate::math::solve`]). The result is offered next to the `=` as a suggestion that also
//! shows what was read (`2⁴ = 16`), so that a misread symbol is noticed. Tapping it with the
//! pen or clicking it inserts the result as a text box; fingers and palms are ignored. Any new
//! stroke drops the suggestion. Off by default (☰ → Input).
use std::sync::Arc;
use std::time::{Duration, Instant};
use glam::Vec2;
use crate::document::{Page, Stroke, StrokeTool};
use crate::math_ink::Recognizer;
/// Pause after the `=` before its expression is computed; a new stroke cancels it
pub const TRIGGER_DELAY: Duration = Duration::from_millis(500);
/// Estimated symbol height relative to the width of the `=`
const SYMBOL_PER_EQUALS: f32 = 1.3;
/// Strokes left of the `=` belong to the expression up to a gap of this many symbol heights
const MAX_GAP: f32 = 2.0;
/// At most this many strokes are read as the expression
const MAX_STROKES: usize = 80;
/// Cap height of the text font relative to its size (Ubuntu: 0.69 em)
const CAP_HEIGHT_PER_EM: f32 = 0.7;
/// A result offered next to an `=`
#[derive(Debug, Clone, PartialEq)]
pub struct Suggestion {
pub page_id: u64,
/// The two strokes of the `=`
pub equals: [u64; 2],
/// What was read, written neatly (`2⁴`)
pub expression: String,
/// The result (`16`)
pub result: String,
/// Where the result goes: left end of the text on the vertical middle of the `=`
pub anchor: Vec2,
/// Height of the handwritten digits in page units
pub symbol_height: f32,
/// Ink color of the `=`
pub color: [f32; 4],
}
impl Suggestion {
/// Top left corner and size (em) of a text box that puts the result next to the `=`, as
/// high as the handwriting
pub fn text_placement(&self, ascent_per_em: f32) -> (Vec2, f32) {
let size = self.symbol_height / CAP_HEIGHT_PER_EM;
// Baseline at the bottom of digits centered on the `=`
let baseline = self.anchor.y + self.symbol_height / 2.0;
(
Vec2::new(self.anchor.x, baseline - ascent_per_em * size),
size,
)
}
}
#[derive(Debug, Clone)]
struct Pending {
page_id: u64,
equals: [u64; 2],
due: Instant,
}
/// State of the math assistant: waiting after an `=`, and the suggestion shown
#[derive(Default, Clone)]
pub struct MathAssist {
pub enabled: bool,
/// Built on first use
recognizer: Option<Arc<Recognizer>>,
pending: Option<Pending>,
suggestion: Option<Suggestion>,
/// Where the UI drew the suggestion (egui points), for [`Self::take_hit`]
pub chip_rect: Option<egui::Rect>,
}
impl std::fmt::Debug for MathAssist {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MathAssist")
.field("enabled", &self.enabled)
.field("pending", &self.pending)
.field("suggestion", &self.suggestion)
.finish_non_exhaustive()
}
}
impl MathAssist {
/// After a stroke was added to `page`: drops the suggestion and waits if the stroke
/// completed an `=`
pub fn stroke_committed(&mut self, page: &Page, now: Instant) {
self.suggestion = None;
self.chip_rect = None;
self.pending = None;
if !self.enabled {
return;
}
if let Some((a, b)) = equals_at_end(&page.strokes) {
self.pending = Some(Pending {
page_id: page.id,
equals: [page.strokes[a].id, page.strokes[b].id],
due: now + TRIGGER_DELAY,
});
}
}
/// When [`Self::update`] has something to do
pub fn deadline(&self) -> Option<Instant> {
self.pending.as_ref().map(|p| p.due)
}
/// Computes the expression once the pause after the `=` is over (not while a stroke is
/// being drawn). True if a suggestion appeared.
pub fn update(
&mut self,
page: &Page,
now: Instant,
drawing: bool,
decimal_comma: bool,
) -> bool {
let Some(pending) = self.pending.as_ref() else {
return false;
};
if drawing || now < pending.due {
return false;
}
let pending = self.pending.take().expect("checked above");
if pending.page_id != page.id || !self.enabled {
return false;
}
let recognizer = self
.recognizer
.get_or_insert_with(|| Arc::new(Recognizer::new()));
self.suggestion = suggest(recognizer, page, pending.equals, decimal_comma);
self.suggestion.is_some()
}
/// The suggestion if it belongs to `page` and its `=` is still there
pub fn suggestion_for(&self, page: &Page) -> Option<&Suggestion> {
self.suggestion.as_ref().filter(|s| {
s.page_id == page.id
&& s.equals
.iter()
.all(|id| page.strokes.iter().any(|stroke| stroke.id == *id))
})
}
/// The suggestion if `pos` (egui points) is on it; it is then taken away
pub fn take_hit(&mut self, pos: egui::Pos2) -> Option<Suggestion> {
if !self.chip_rect.is_some_and(|r| r.contains(pos)) {
return None;
}
self.chip_rect = None;
self.suggestion.take()
}
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
if !enabled {
self.pending = None;
self.suggestion = None;
self.chip_rect = None;
}
}
}
/// Handwriting that can be read: pen strokes (also snapped lines), no highlighter, text or
/// images
fn is_ink(stroke: &Stroke) -> bool {
!stroke.is_object() && stroke.tool != StrokeTool::Highlighter && !stroke.points.is_empty()
}
fn positions(stroke: &Stroke) -> Vec<Vec2> {
stroke.points.iter().map(|s| s.position).collect()
}
/// A straight, roughly horizontal stroke: one bar of an `=`
fn is_bar(stroke: &Stroke) -> bool {
let size = stroke.aabb.max - stroke.aabb.min;
let length: f32 = positions(stroke)
.windows(2)
.map(|w| w[0].distance(w[1]))
.sum();
size.x > 0.0 && size.x >= 2.5 * size.y && length <= 1.3 * size.x
}
/// Indices of the last two ink strokes if they form an `=`: two bars of similar length, one
/// above the other
fn equals_at_end(strokes: &[Stroke]) -> Option<(usize, usize)> {
let mut ink = strokes.iter().enumerate().rev().filter(|(_, s)| is_ink(s));
let (b, second) = ink.next()?;
let (a, first) = ink.next()?;
if !is_bar(first) || !is_bar(second) {
return None;
}
let (w1, w2) = (
first.aabb.max.x - first.aabb.min.x,
second.aabb.max.x - second.aabb.min.x,
);
let (short, long) = (w1.min(w2), w1.max(w2));
let overlap = first.aabb.max.x.min(second.aabb.max.x) - first.aabb.min.x.max(second.aabb.min.x);
let dy = ((first.aabb.min.y + first.aabb.max.y) - (second.aabb.min.y + second.aabb.max.y))
.abs()
/ 2.0;
(overlap >= 0.5 * short && long <= 2.0 * short && dy >= 0.1 * long && dy <= long)
.then_some((a, b))
}
/// Indices of the strokes left of the `=` that make up its expression, in the order they were
/// written: ink in a band around the `=`, going left until a wide gap
fn expression_strokes(strokes: &[Stroke], equals: (usize, usize)) -> Vec<usize> {
let (e1, e2) = (&strokes[equals.0], &strokes[equals.1]);
let min = e1.aabb.min.min(e2.aabb.min);
let max = e1.aabb.max.max(e2.aabb.max);
let height = (max.x - min.x) * SYMBOL_PER_EQUALS;
let middle = (min.y + max.y) / 2.0;
let mut candidates: Vec<usize> = (0..strokes.len())
.filter(|&i| i != equals.0 && i != equals.1 && is_ink(&strokes[i]))
.filter(|&i| {
let s = &strokes[i].aabb;
let center = (s.min.y + s.max.y) / 2.0;
(center - middle).abs() <= 2.0 * height
&& s.max.y - s.min.y <= 3.0 * height
&& s.max.x <= min.x + 0.2 * height
})
.collect();
candidates.sort_by(|&a, &b| strokes[b].aabb.max.x.total_cmp(&strokes[a].aabb.max.x));
let mut left = min.x;
let mut chosen = Vec::new();
for i in candidates {
let s = &strokes[i].aabb;
if s.max.x < left - MAX_GAP * height || chosen.len() >= MAX_STROKES {
break;
}
left = left.min(s.min.x);
chosen.push(i);
}
chosen.sort_unstable();
chosen
}
/// Reads and computes the expression in front of the `=` made of the strokes `equals`
fn suggest(
recognizer: &Recognizer,
page: &Page,
equals: [u64; 2],
decimal_comma: bool,
) -> Option<Suggestion> {
let strokes = &page.strokes;
let index = |id: u64| strokes.iter().position(|s| s.id == id);
let pair = (index(equals[0])?, index(equals[1])?);
let mut chosen = expression_strokes(strokes, pair);
if chosen.is_empty() {
return None;
}
chosen.extend([pair.0, pair.1]);
let points: Vec<Vec<Vec2>> = chosen.iter().map(|&i| positions(&strokes[i])).collect();
let recognition = recognizer.recognize(&points)?;
// The `=` must be read as the last symbol, from exactly its two strokes
let last = recognition.symbols.last()?;
let equals_strokes = [points.len() - 2, points.len() - 1];
let mut last_strokes = last.strokes.clone();
last_strokes.sort_unstable();
if last.ch != '=' || last_strokes != equals_strokes {
return None;
}
// In a chain like `x = 2 + 3 =` only the part after the previous `=` is computed
let text = recognition.text.strip_suffix('=')?;
let expression = text.rsplit('=').next()?;
// A dot or comma that was written decides the separator of the result
let decimal_comma = if expression.contains(',') {
true
} else if expression.contains('.') {
false
} else {
decimal_comma
};
let result = crate::math::solve(expression, decimal_comma)?;
let shown = crate::math::display(expression, decimal_comma)?;
let (e1, e2) = (&strokes[pair.0], &strokes[pair.1]);
let right = e1.aabb.max.x.max(e2.aabb.max.x);
let middle = (e1.aabb.min.y.min(e2.aabb.min.y) + e1.aabb.max.y.max(e2.aabb.max.y)) / 2.0;
let mut heights: Vec<f32> = recognition
.symbols
.iter()
.filter(|s| s.ch.is_ascii_digit() || s.ch.is_ascii_alphabetic())
.map(|s| s.max.y - s.min.y)
.collect();
heights.sort_by(f32::total_cmp);
let width = right - e1.aabb.min.x.min(e2.aabb.min.x);
let symbol_height = heights
.last()
.copied()
.unwrap_or(width * SYMBOL_PER_EQUALS)
.max(1.0);
Some(Suggestion {
page_id: page.id,
equals,
expression: shown,
result,
anchor: Vec2::new(right + 0.4 * symbol_height, middle),
symbol_height,
color: e2.color,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::StylusSample;
use crate::math_ink::tests::{write_line, Jitter};
const BLACK: [f32; 4] = [0.0, 0.0, 0.0, 1.0];
fn stroke(id: u64, points: &[Vec2]) -> Stroke {
let samples = points
.iter()
.enumerate()
.map(|(i, &position)| StylusSample {
position,
pressure: 0.5,
timestamp: i as f64 * 0.01,
})
.collect();
Stroke::new(id, samples, Vec::new(), BLACK)
}
/// A page with `text` written at `origin`, scaled by `scale`
fn page_with(lines: &[(&str, Vec2)], scale: f32) -> Page {
let mut page = Page::new(7, crate::document::PageBackground::Blank);
let mut id = 1;
for (seed, (text, origin)) in lines.iter().enumerate() {
for points in write_line(text, &mut Jitter(seed as u64 + 3)) {
let points: Vec<Vec2> = points.iter().map(|p| *origin + *p * scale).collect();
page.add_stroke(stroke(id, &points));
id += 1;
}
}
page
}
fn run(page: &Page) -> Option<Suggestion> {
let mut assist = MathAssist {
enabled: true,
..Default::default()
};
let start = Instant::now();
assist.stroke_committed(page, start);
assert!(!assist.update(page, start, false, true), "too early");
assist.update(page, start + TRIGGER_DELAY, false, true);
assist.suggestion_for(page).cloned()
}
#[test]
fn suggests_result_after_equals() {
let page = page_with(&[("(x+3)^2=", Vec2::new(100.0, 200.0))], 3.0);
let suggestion = run(&page).unwrap();
assert_eq!(suggestion.expression, "(x + 3)²");
assert_eq!(suggestion.result, "x² + 6x + 9");
let equals_right = page
.strokes
.iter()
.rev()
.take(2)
.map(|s| s.aabb.max.x)
.fold(f32::MIN, f32::max);
assert!(suggestion.anchor.x > equals_right);
// The handwriting is 14 units high, scaled by 3
assert!(
(suggestion.symbol_height - 42.0).abs() < 6.0,
"{suggestion:?}"
);
}
#[test]
fn ignores_writing_far_to_the_left_and_on_other_lines() {
let page = page_with(
&[
("7", Vec2::new(0.0, 200.0)),
("5·5", Vec2::new(0.0, 300.0)),
("2^4=", Vec2::new(150.0, 200.0)),
],
2.0,
);
let suggestion = run(&page).unwrap();
assert_eq!(suggestion.expression, "2⁴");
assert_eq!(suggestion.result, "16");
}
#[test]
fn computes_the_last_part_of_a_chain() {
let page = page_with(&[("x=2·3=", Vec2::new(0.0, 0.0))], 2.0);
assert_eq!(run(&page).unwrap().result, "6");
}
#[test]
fn nothing_without_equals_or_when_disabled() {
let page = page_with(&[("2+3", Vec2::ZERO)], 2.0);
assert!(run(&page).is_none());
let page = page_with(&[("2+3=", Vec2::ZERO)], 2.0);
let mut assist = MathAssist::default();
assist.stroke_committed(&page, Instant::now());
assert!(assist.deadline().is_none());
}
#[test]
fn new_stroke_or_removed_equals_drops_the_suggestion() {
let mut page = page_with(&[("1+1=", Vec2::ZERO)], 2.0);
let mut assist = MathAssist {
enabled: true,
..Default::default()
};
let start = Instant::now();
assist.stroke_committed(&page, start);
// A stroke still being drawn when the time is up: nothing yet
assert!(!assist.update(&page, start + TRIGGER_DELAY, true, true));
assert!(assist.update(&page, start + TRIGGER_DELAY, false, true));
assert_eq!(assist.suggestion_for(&page).unwrap().result, "2");
let last = page.strokes.last().unwrap().id;
page.remove_strokes_by_ids(&[last]);
assert!(assist.suggestion_for(&page).is_none());
assist.stroke_committed(&page, start);
assert!(assist.suggestion.is_none());
}
#[test]
fn hit_takes_the_suggestion() {
let page = page_with(&[("1+1=", Vec2::ZERO)], 2.0);
let mut assist = MathAssist {
enabled: true,
..Default::default()
};
let start = Instant::now();
assist.stroke_committed(&page, start);
assist.update(&page, start + TRIGGER_DELAY, false, true);
assist.chip_rect = Some(egui::Rect::from_min_size(
egui::pos2(10.0, 10.0),
egui::vec2(50.0, 20.0),
));
assert!(assist.take_hit(egui::pos2(5.0, 5.0)).is_none());
assert_eq!(assist.take_hit(egui::pos2(20.0, 20.0)).unwrap().result, "2");
assert!(assist.take_hit(egui::pos2(20.0, 20.0)).is_none());
}
#[test]
fn result_text_sits_on_the_baseline() {
let suggestion = Suggestion {
page_id: 1,
equals: [1, 2],
expression: "1 + 1".into(),
result: "2".into(),
anchor: Vec2::new(100.0, 50.0),
symbol_height: 14.0,
color: BLACK,
};
let (top_left, size) = suggestion.text_placement(0.9);
assert!((size - 20.0).abs() < 0.01);
// Baseline (top + ascent) at the bottom of digits centered on the `=`
assert!((top_left.y + 0.9 * size - 57.0).abs() < 0.01);
assert_eq!(top_left.x, 100.0);
}
}
+1259
View File
File diff suppressed because it is too large. Load diff
+853
View File
@@ -0,0 +1,853 @@
//! Talking to a Nextcloud server (Issue #49, stage 2): finding the server, signing in through
//! the browser (Login Flow v2, the app gets its own app password), and WebDAV access to the
//! folder `ink-notes` in the user's files. The sync itself is in `cloud_sync.rs`.
//!
//! Private servers often use a self-signed certificate. Such a certificate is trusted once the
//! user has confirmed its SHA-256 fingerprint; it is then pinned for this server.
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use base64::Engine;
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{DigitallySignedStruct, SignatureScheme};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::t;
/// Name of the synced folder in the user's files.
pub const REMOTE_FOLDER: &str = "ink-notes";
/// Largest file the sync downloads (bigger ones are surely not ours).
const MAX_DOWNLOAD: u64 = 512 * 1024 * 1024;
/// A connected Nextcloud account, stored in `nextcloud.json` next to the settings.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Account {
/// Server address including a subfolder, without trailing slash
pub server: String,
/// Login name for the app password
pub login_name: String,
/// User id in WebDAV paths (differs from the login name e.g. with e-mail logins)
pub user_id: String,
pub app_password: String,
/// SHA-256 fingerprint of a certificate the user trusts although it isn't signed by a
/// known authority
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pinned_cert: Option<String>,
}
impl Account {
pub fn file_path() -> Option<PathBuf> {
crate::paths::config_dir().map(|d| d.join("nextcloud.json"))
}
pub fn load_from(path: &Path) -> Option<Self> {
serde_json::from_slice(&std::fs::read(path).ok()?).ok()
}
/// Stores the account; the file is only readable by the user.
pub fn save_to(&self, path: &Path) -> Result<(), String> {
if let Some(dir) = path.parent() {
crate::storage::create_secure_dir_all(dir).map_err(|e| e.to_string())?;
}
crate::storage::atomic_write_json(path, self).map_err(|e| e.to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
Ok(())
}
/// `user@server` for the interface.
pub fn label(&self) -> String {
let host = url::Url::parse(&self.server)
.ok()
.and_then(|u| {
let port = u.port().map(|p| format!(":{p}")).unwrap_or_default();
u.host_str()
.map(|h| format!("{h}{port}{}", u.path().trim_end_matches('/')))
})
.unwrap_or_else(|| self.server.clone());
format!("{}@{host}", self.login_name)
}
pub fn is_insecure(&self) -> bool {
self.server.starts_with("http://")
}
}
/// Why talking to the server failed.
#[derive(Debug, Clone, PartialEq)]
pub enum Error {
/// The certificate isn't signed by a known authority (or doesn't match the pinned one)
Untrusted {
fingerprint: String,
},
/// Server not reachable (offline, wrong address)
Offline(String),
/// App password revoked or wrong
Unauthorized,
NotFound,
/// The file changed on the server since we last saw it
PreconditionFailed,
Other(String),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::Untrusted { fingerprint } => write!(
f,
"{}",
t!(
"Zertifikat nicht vertrauenswürdig (SHA-256 {fingerprint})",
fingerprint = fingerprint
)
),
Error::Offline(msg) => write!(f, "{}", t!("Server nicht erreichbar: {msg}", msg = msg)),
Error::Unauthorized => write!(f, "{}", t!("Anmeldung abgelehnt – bitte neu verbinden")),
Error::NotFound => write!(f, "{}", t!("Nicht gefunden")),
Error::PreconditionFailed => write!(f, "{}", t!("Inzwischen geändert")),
Error::Other(msg) => write!(f, "{msg}"),
}
}
}
/// Server address from what the user typed: `https://` is added when missing, a pasted link
/// into the web interface is cut back to the server (a subfolder such as `/nextcloud` stays).
pub fn normalize_server(input: &str) -> Result<String, String> {
let input = input.trim();
if input.is_empty() {
return Err(t!("Bitte die Adresse des Nextcloud-Servers eingeben").to_string());
}
let with_scheme = if input.contains("://") {
input.to_string()
} else {
format!("https://{input}")
};
let mut url = url::Url::parse(&with_scheme).map_err(|e| t!("Ungültige Adresse: {e}", e = e))?;
if !matches!(url.scheme(), "https" | "http") || url.host_str().is_none() {
return Err(t!("Die Adresse muss mit https:// beginnen").to_string());
}
url.set_query(None);
url.set_fragment(None);
let _ = url.set_username("");
let _ = url.set_password(None);
let mut path = url.path().to_string();
for marker in [
"/index.php",
"/apps/",
"/login",
"/remote.php",
"/status.php",
"/s/",
] {
if let Some(pos) = path.find(marker) {
path.truncate(pos);
}
}
url.set_path(path.trim_end_matches('/'));
Ok(url.as_str().trim_end_matches('/').to_string())
}
/// SHA-256 fingerprint as colon-separated hex pairs, as browsers show it.
pub fn fingerprint(cert: &[u8]) -> String {
Sha256::digest(cert)
.iter()
.map(|b| format!("{b:02X}"))
.collect::<Vec<_>>()
.join(":")
}
/// Accepts what the usual authorities sign and, besides, the one pinned certificate. Remembers
/// the fingerprint of a rejected certificate so the user can be asked about it.
#[derive(Debug)]
struct Verifier {
webpki: Arc<rustls::client::WebPkiServerVerifier>,
provider: Arc<rustls::crypto::CryptoProvider>,
pinned: Option<String>,
rejected: Arc<Mutex<Option<String>>>,
}
impl ServerCertVerifier for Verifier {
fn verify_server_cert(
&self,
end_entity: &CertificateDer<'_>,
intermediates: &[CertificateDer<'_>],
server_name: &ServerName<'_>,
ocsp_response: &[u8],
now: UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
match self.webpki.verify_server_cert(
end_entity,
intermediates,
server_name,
ocsp_response,
now,
) {
Ok(ok) => Ok(ok),
Err(err) => {
let print = fingerprint(end_entity);
if self.pinned.as_deref() == Some(print.as_str()) {
return Ok(ServerCertVerified::assertion());
}
if let Ok(mut slot) = self.rejected.lock() {
*slot = Some(print);
}
Err(err)
}
}
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&self.provider.signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&self.provider.signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
self.provider
.signature_verification_algorithms
.supported_schemes()
}
}
/// HTTP client for one server, with the pinned certificate if any.
pub struct Client {
agent: ureq::Agent,
rejected: Arc<Mutex<Option<String>>>,
user_agent: String,
}
impl Client {
pub fn new(pinned: Option<String>) -> Self {
let provider = Arc::new(rustls::crypto::ring::default_provider());
let mut roots = rustls::RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let rejected = Arc::new(Mutex::new(None));
let mut agent = ureq::AgentBuilder::new()
.timeout_connect(Duration::from_secs(10))
.timeout_read(Duration::from_secs(120))
.timeout_write(Duration::from_secs(120))
.redirects(0);
let webpki = rustls::client::WebPkiServerVerifier::builder_with_provider(
Arc::new(roots),
provider.clone(),
)
.build();
let config = webpki.map_err(|e| e.to_string()).and_then(|webpki| {
let verifier = Verifier {
webpki,
provider: provider.clone(),
pinned,
rejected: rejected.clone(),
};
rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map(|b| {
b.dangerous()
.with_custom_certificate_verifier(Arc::new(verifier))
.with_no_client_auth()
})
.map_err(|e| e.to_string())
});
match config {
Ok(config) => agent = agent.tls_config(Arc::new(config)),
Err(err) => eprintln!("[Nextcloud] TLS-Einrichtung fehlgeschlagen: {err}"),
}
Self {
agent: agent.build(),
rejected,
user_agent: format!("ink-notes ({})", crate::storage::device_name()),
}
}
fn request(&self, method: &str, url: &str) -> ureq::Request {
if let Ok(mut slot) = self.rejected.lock() {
*slot = None;
}
self.agent
.request(method, url)
.set("User-Agent", &self.user_agent)
}
/// Turns a failed request into an [`Error`].
fn error(&self, err: ureq::Error) -> Error {
match err {
ureq::Error::Status(401, _) => Error::Unauthorized,
ureq::Error::Status(404, _) => Error::NotFound,
ureq::Error::Status(412, _) => Error::PreconditionFailed,
ureq::Error::Status(code, resp) => {
let location = resp.header("Location").map(str::to_string);
match location {
Some(to) if (300..400).contains(&code) => Error::Other(t!(
"Der Server leitet nach {to} weiter – bitte diese Adresse verwenden",
to = to
)),
_ => Error::Other(t!("Server-Fehler {code}", code = code)),
}
}
ureq::Error::Transport(transport) => {
if let Some(print) = self.rejected.lock().ok().and_then(|s| s.clone()) {
return Error::Untrusted { fingerprint: print };
}
Error::Offline(transport.to_string())
}
}
}
}
/// Checks that a Nextcloud server answers at `server`.
pub fn check_server(client: &Client, server: &str) -> Result<(), Error> {
let resp = client
.request("GET", &format!("{server}/status.php"))
.call()
.map_err(|e| client.error(e))?;
let status: serde_json::Value = resp.into_json().map_err(|_| not_nextcloud())?;
if status.get("installed").and_then(|v| v.as_bool()) == Some(true) {
Ok(())
} else {
Err(not_nextcloud())
}
}
fn not_nextcloud() -> Error {
Error::Other(t!("Unter dieser Adresse antwortet kein Nextcloud-Server").to_string())
}
/// A started sign-in: the user approves it in the browser, the app polls for the result.
#[derive(Debug, Clone, PartialEq)]
pub struct LoginFlow {
pub login_url: String,
poll_url: String,
token: String,
}
/// Puts a link the server built with its own idea of its address (wrong behind a proxy) onto
/// the address the user gave.
fn onto_server(link: &str, server: &str, marker: &str) -> String {
let same_origin = match (url::Url::parse(link), url::Url::parse(server)) {
(Ok(a), Ok(b)) => a.origin() == b.origin(),
_ => false,
};
match link.find(marker) {
Some(pos) if !same_origin => format!("{server}/index.php{}", &link[pos..]),
_ => link.to_string(),
}
}
/// Starts Login Flow v2; the app password gets the device name as its name.
pub fn start_login(client: &Client, server: &str) -> Result<LoginFlow, Error> {
#[derive(Deserialize)]
struct Poll {
token: String,
endpoint: String,
}
#[derive(Deserialize)]
struct Start {
poll: Poll,
login: String,
}
let resp = client
.request("POST", &format!("{server}/index.php/login/v2"))
.call()
.map_err(|e| client.error(e))?;
let start: Start = resp.into_json().map_err(|_| not_nextcloud())?;
Ok(LoginFlow {
login_url: onto_server(&start.login, server, "/login/v2"),
poll_url: onto_server(&start.poll.endpoint, server, "/login/v2"),
token: start.poll.token,
})
}
/// Login name and app password once the user has approved the sign-in.
pub fn poll_login(client: &Client, flow: &LoginFlow) -> Result<Option<(String, String)>, Error> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Done {
login_name: String,
app_password: String,
}
match client
.request("POST", &flow.poll_url)
.send_form(&[("token", flow.token.as_str())])
{
Ok(resp) => {
let done: Done = resp.into_json().map_err(|e| Error::Other(e.to_string()))?;
Ok(Some((done.login_name, done.app_password)))
}
// Not approved yet
Err(ureq::Error::Status(404, _)) => Ok(None),
Err(err) => Err(client.error(err)),
}
}
fn basic_auth(login_name: &str, password: &str) -> String {
let raw = format!("{login_name}:{password}");
format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode(raw)
)
}
/// User id of the signed-in user (the name in WebDAV paths).
pub fn user_id(
client: &Client,
server: &str,
login_name: &str,
password: &str,
) -> Result<String, Error> {
let resp = client
.request(
"GET",
&format!("{server}/ocs/v2.php/cloud/user?format=json"),
)
.set("OCS-APIRequest", "true")
.set("Authorization", &basic_auth(login_name, password))
.call()
.map_err(|e| client.error(e))?;
let value: serde_json::Value = resp.into_json().map_err(|e| Error::Other(e.to_string()))?;
value["ocs"]["data"]["id"]
.as_str()
.map(str::to_string)
.ok_or_else(|| Error::Other(t!("Unerwartete Antwort des Servers").to_string()))
}
/// Revokes the app password on the server (when disconnecting).
pub fn revoke(account: &Account) -> Result<(), Error> {
let client = Client::new(account.pinned_cert.clone());
client
.request(
"DELETE",
&format!("{}/ocs/v2.php/core/apppassword", account.server),
)
.set("OCS-APIRequest", "true")
.set(
"Authorization",
&basic_auth(&account.login_name, &account.app_password),
)
.call()
.map(|_| ())
.map_err(|e| client.error(e))
}
/// One entry of a folder listing on the server.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
/// Path relative to the synced folder, `/`-separated
pub path: String,
pub is_dir: bool,
pub etag: String,
}
/// How an upload must find the file on the server.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Expect<'a> {
/// Must not exist yet
New,
/// Must still have this ETag
Etag(&'a str),
}
/// The folder `ink-notes` on the server, as the sync sees it.
pub trait Remote {
/// Entries directly in `dir` (`""` is the synced folder); `None` if it doesn't exist.
fn list(&mut self, dir: &str) -> Result<Option<Vec<Entry>>, Error>;
/// Content and ETag of a file.
fn get(&mut self, path: &str) -> Result<(Vec<u8>, String), Error>;
/// Uploads a file and returns its new ETag.
fn put(&mut self, path: &str, data: &[u8], expect: Expect<'_>) -> Result<String, Error>;
/// Creates a folder (fine if it exists).
fn mkdir(&mut self, path: &str) -> Result<(), Error>;
/// Deletes a file or folder (into the server's trash bin); with an ETag only if unchanged.
fn delete(&mut self, path: &str, etag: Option<&str>) -> Result<(), Error>;
}
/// [`Remote`] over WebDAV.
pub struct WebDav {
client: Client,
/// `…/remote.php/dav/files/<user>/ink-notes/`
root: url::Url,
auth: String,
}
impl WebDav {
pub fn new(account: &Account) -> Result<Self, Error> {
Self::with_folder(account, REMOTE_FOLDER)
}
/// Like [`Self::new`], with another folder (for tests against a real server).
pub fn with_folder(account: &Account, folder: &str) -> Result<Self, Error> {
let mut root = url::Url::parse(&format!("{}/", account.server))
.map_err(|e| Error::Other(e.to_string()))?;
root.path_segments_mut()
.map_err(|_| Error::Other(account.server.clone()))?
.pop_if_empty()
.extend(["remote.php", "dav", "files", &account.user_id, folder, ""]);
Ok(Self {
client: Client::new(account.pinned_cert.clone()),
root,
auth: basic_auth(&account.login_name, &account.app_password),
})
}
fn url(&self, path: &str, dir: bool) -> String {
let mut url = self.root.clone();
if let Ok(mut segments) = url.path_segments_mut() {
segments.pop_if_empty();
if !path.is_empty() {
segments.extend(path.split('/'));
}
if dir {
segments.push("");
}
}
url.to_string()
}
fn request(&self, method: &str, url: &str) -> ureq::Request {
self.client
.request(method, url)
.set("Authorization", &self.auth)
}
}
/// Decodes `%xx` escapes of a URL path.
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("");
if let Ok(b) = u8::from_str_radix(hex, 16) {
out.push(b);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
/// Entries of a PROPFIND answer: (decoded href path, is folder, ETag).
pub fn parse_multistatus(xml: &str) -> Result<Vec<(String, bool, String)>, String> {
use quick_xml::events::Event;
let mut reader = quick_xml::Reader::from_str(xml);
let mut entries = Vec::new();
let mut href = String::new();
let mut etag = String::new();
let mut is_dir = false;
let mut ok = false;
let mut in_tag: Option<&'static str> = None;
loop {
match reader.read_event().map_err(|e| e.to_string())? {
Event::Start(e) => {
let name = e.local_name();
match name.as_ref() {
b"response" => {
href.clear();
etag.clear();
is_dir = false;
ok = false;
}
b"href" => in_tag = Some("href"),
b"getetag" => in_tag = Some("etag"),
b"status" => in_tag = Some("status"),
b"collection" => is_dir = true,
_ => {}
}
}
Event::Empty(e) => {
if e.local_name().as_ref() == b"collection" {
is_dir = true;
}
}
event @ (Event::Text(_) | Event::GeneralRef(_)) => {
let text = match event {
Event::Text(t) => t.decode().map_err(|e| e.to_string())?.into_owned(),
Event::GeneralRef(r) => match r.resolve_char_ref() {
Ok(Some(c)) => c.to_string(),
_ => r
.decode()
.ok()
.and_then(|name| quick_xml::escape::resolve_predefined_entity(&name))
.unwrap_or_default()
.to_string(),
},
_ => String::new(),
};
match in_tag {
Some("href") => href.push_str(&text),
Some("etag") => etag.push_str(&text),
Some("status") => ok |= text.contains(" 200 "),
_ => {}
}
}
Event::End(e) => {
let name = e.local_name();
match name.as_ref() {
b"href" | b"getetag" | b"status" => in_tag = None,
b"response" if ok => {
entries.push((percent_decode(href.trim()), is_dir, etag.trim().to_string()))
}
_ => {}
}
}
Event::Eof => break,
_ => {}
}
}
Ok(entries)
}
/// ETag of a file in an answer, as PROPFIND lists it. Nextcloud's own `OC-ETag` is left alone
/// by the web server; a plain `ETag` may carry a mark of the compression.
fn etag_of(resp: &ureq::Response) -> Option<String> {
if let Some(etag) = resp.header("OC-ETag") {
return Some(etag.to_string());
}
let etag = resp.header("ETag")?;
let plain = etag.strip_suffix('"').and_then(|unquoted| {
["-gzip", "-br", "-zstd", "-df"]
.iter()
.find_map(|mark| unquoted.strip_suffix(mark))
});
Some(plain.map_or(etag.to_string(), |tag| format!("{tag}\"")))
}
const PROPFIND_BODY: &str = r#"<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:getetag/><d:resourcetype/></d:prop></d:propfind>"#;
impl Remote for WebDav {
fn list(&mut self, dir: &str) -> Result<Option<Vec<Entry>>, Error> {
let url = self.url(dir, true);
let resp = match self
.request("PROPFIND", &url)
.set("Depth", "1")
.set("Content-Type", "application/xml; charset=utf-8")
.send_string(PROPFIND_BODY)
{
Ok(resp) => resp,
Err(ureq::Error::Status(404, _)) => return Ok(None),
Err(err) => return Err(self.client.error(err)),
};
let body = resp
.into_string()
.map_err(|e| Error::Offline(e.to_string()))?;
let root_path = percent_decode(self.root.path());
let entries = parse_multistatus(&body).map_err(Error::Other)?;
Ok(Some(
entries
.into_iter()
.filter_map(|(href, is_dir, etag)| {
// Hrefs are paths, sometimes full URLs
let path = match href.find("/remote.php/") {
Some(pos) if href.contains("://") => href[pos..].to_string(),
_ => href,
};
let path = path.strip_prefix(&root_path)?.trim_end_matches('/');
(path != dir && !path.is_empty()).then(|| Entry {
path: path.to_string(),
is_dir,
etag,
})
})
.collect(),
))
}
fn get(&mut self, path: &str) -> Result<(Vec<u8>, String), Error> {
// Uncompressed: Apache marks the ETag of a compressed answer (`"…-gzip"`)
let resp = self
.request("GET", &self.url(path, false))
.set("Accept-Encoding", "identity")
.call()
.map_err(|e| self.client.error(e))?;
let etag = etag_of(&resp).unwrap_or_default();
let mut data = Vec::new();
resp.into_reader()
.take(MAX_DOWNLOAD)
.read_to_end(&mut data)
.map_err(|e| Error::Offline(e.to_string()))?;
Ok((data, etag))
}
fn put(&mut self, path: &str, data: &[u8], expect: Expect<'_>) -> Result<String, Error> {
let mut req = self.request("PUT", &self.url(path, false));
req = match expect {
Expect::New => req.set("If-None-Match", "*"),
Expect::Etag(etag) => req.set("If-Match", etag),
};
let resp = req.send_bytes(data).map_err(|e| self.client.error(e))?;
match etag_of(&resp) {
Some(etag) => Ok(etag),
None => {
// Look it up when the server didn't say
let parent = path.rsplit_once('/').map_or("", |(p, _)| p);
self.list(parent)?
.unwrap_or_default()
.into_iter()
.find(|e| e.path == path)
.map(|e| e.etag)
.ok_or(Error::NotFound)
}
}
}
fn mkdir(&mut self, path: &str) -> Result<(), Error> {
match self.request("MKCOL", &self.url(path, true)).call() {
Ok(_) | Err(ureq::Error::Status(405, _)) => Ok(()),
Err(err) => Err(self.client.error(err)),
}
}
fn delete(&mut self, path: &str, etag: Option<&str>) -> Result<(), Error> {
let mut req = self.request("DELETE", &self.url(path, false));
if let Some(etag) = etag {
req = req.set("If-Match", etag);
}
match req.call() {
Ok(_) | Err(ureq::Error::Status(404, _)) => Ok(()),
Err(err) => Err(self.client.error(err)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalizes_server_addresses() {
assert_eq!(
normalize_server("cloud.example.org").unwrap(),
"https://cloud.example.org"
);
assert_eq!(
normalize_server(" https://example.org/nextcloud/ ").unwrap(),
"https://example.org/nextcloud"
);
assert_eq!(
normalize_server("https://example.org/nextcloud/index.php/apps/files/?dir=/").unwrap(),
"https://example.org/nextcloud"
);
assert_eq!(
normalize_server("http://192.168.1.5:8080/apps/dashboard/").unwrap(),
"http://192.168.1.5:8080"
);
assert!(normalize_server("").is_err());
assert!(normalize_server("ftp://example.org").is_err());
}
#[test]
fn moves_links_onto_the_given_server() {
let server = "https://cloud.example.org/nc";
assert_eq!(
onto_server("http://localhost/login/v2/poll", server, "/login/v2"),
"https://cloud.example.org/nc/index.php/login/v2/poll"
);
let same = "https://cloud.example.org/nc/login/v2/flow/abc";
assert_eq!(onto_server(same, server, "/login/v2"), same);
}
#[test]
fn parses_propfind_answers() {
let xml = r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:"><d:response><d:href>/remote.php/dav/files/pia/ink-notes/</d:href><d:propstat><d:prop><d:getetag>&quot;abc&quot;</d:getetag><d:resourcetype><d:collection/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
<d:response><d:href>/remote.php/dav/files/pia/ink-notes/library.json</d:href><d:propstat><d:prop><d:getetag>&quot;e1&quot;</d:getetag><d:resourcetype/></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
<d:response><d:href>/remote.php/dav/files/pia/ink-notes/Mathe%20%C3%BC/</d:href><d:propstat><d:prop><d:getetag>"e2"</d:getetag><d:resourcetype><d:collection/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>"#;
let entries = parse_multistatus(xml).unwrap();
assert_eq!(
entries,
vec![
(
"/remote.php/dav/files/pia/ink-notes/".to_string(),
true,
"\"abc\"".to_string()
),
(
"/remote.php/dav/files/pia/ink-notes/library.json".to_string(),
false,
"\"e1\"".to_string()
),
(
"/remote.php/dav/files/pia/ink-notes/Mathe ü/".to_string(),
true,
"\"e2\"".to_string()
),
]
);
}
#[test]
fn builds_webdav_urls() {
let account = Account {
server: "https://example.org/nc".into(),
login_name: "pia@example.org".into(),
user_id: "pia".into(),
app_password: "x".into(),
pinned_cert: None,
};
let dav = WebDav::new(&account).unwrap();
assert_eq!(
dav.url("nb 1/page_1.json", false),
"https://example.org/nc/remote.php/dav/files/pia/ink-notes/nb%201/page_1.json"
);
assert_eq!(
dav.url("", true),
"https://example.org/nc/remote.php/dav/files/pia/ink-notes/"
);
assert_eq!(account.label(), "pia@example.org@example.org/nc");
}
#[test]
fn account_round_trip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cfg").join("nextcloud.json");
let account = Account {
server: "https://example.org".into(),
login_name: "pia".into(),
user_id: "pia".into(),
app_password: "secret".into(),
pinned_cert: Some("AA:BB".into()),
};
account.save_to(&path).unwrap();
assert_eq!(Account::load_from(&path), Some(account));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
}
}
+71 -3
View File
@@ -17,11 +17,39 @@ mod wayland;
#[cfg(target_os = "linux")]
pub use wayland::KeyboardRequester;
/// Elsewhere the system shows the on-screen keyboard by itself; nothing to request.
#[cfg(not(target_os = "linux"))]
/// Android: the soft keyboard is shown only when asked to (Issue #42). Its text comes through
/// the activity's input connection (`android_bridge`); keys like Enter or Backspace may also
/// arrive as key events through winit.
#[cfg(target_os = "android")]
pub struct KeyboardRequester;
#[cfg(not(target_os = "linux"))]
#[cfg(target_os = "android")]
impl KeyboardRequester {
/// # Safety
/// Nothing to uphold; `unsafe` like the Wayland version.
pub unsafe fn for_window(_window: &winit::window::Window) -> Option<Self> {
Some(Self)
}
pub fn request(&mut self) {
crate::android_bridge::show_keyboard(true);
}
pub fn release(&mut self) {
crate::android_bridge::show_keyboard(false);
}
/// Typed text and deletions; taking them is left to the app's frame (see `app.rs`)
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
Vec::new()
}
}
/// Elsewhere the system shows the on-screen keyboard by itself; nothing to request.
#[cfg(not(any(target_os = "linux", target_os = "android")))]
pub struct KeyboardRequester;
#[cfg(not(any(target_os = "linux", target_os = "android")))]
impl KeyboardRequester {
/// # Safety
/// Nothing to uphold; `unsafe` like the Wayland version.
@@ -31,6 +59,8 @@ impl KeyboardRequester {
pub fn request(&mut self) {}
pub fn release(&mut self) {}
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
Vec::new()
}
@@ -75,6 +105,30 @@ pub fn deletion_key_events(back: u32, forward: u32) -> Vec<egui::Event> {
.collect()
}
/// egui input for committed text: line breaks become the Enter key, as egui's text fields
/// take them only that way.
pub fn committed_text_events(text: &str) -> Vec<egui::Event> {
let mut events = Vec::new();
for (i, line) in text.split('\n').enumerate() {
if i > 0 {
for pressed in [true, false] {
events.push(egui::Event::Key {
key: egui::Key::Enter,
physical_key: None,
pressed,
repeat: false,
modifiers: egui::Modifiers::NONE,
});
}
}
let line = line.trim_end_matches('\r');
if !line.is_empty() {
events.push(egui::Event::Text(line.to_string()));
}
}
events
}
/// Entscheidet, ob die Bildschirmtastatur angefordert wird: sobald ein Textfeld den Fokus
/// bekommt und erneut bei jedem Antippen des fokussierten Felds (falls die Tastatur inzwischen
/// zugeklappt wurde).
@@ -165,6 +219,20 @@ mod tests {
assert!(deletion_key_events(0, 0).is_empty());
}
#[test]
fn committed_line_breaks_become_enter() {
let events = committed_text_events("ab\nc\n");
let kinds: Vec<String> = events
.iter()
.map(|e| match e {
egui::Event::Text(t) => t.clone(),
egui::Event::Key { pressed: true, .. } => "⏎".into(),
_ => "-".into(),
})
.collect();
assert_eq!(kinds, ["ab", "⏎", "-", "c", "⏎", "-"]);
}
#[test]
fn no_request_without_focused_text_field() {
assert!(!should_request(false, false, true));
+4
View File
@@ -92,6 +92,10 @@ impl KeyboardRequester {
}
}
/// The text field lost focus. Nothing to do: the compositor hides the keyboard once
/// winit disables text input.
pub fn release(&mut self) {}
/// Tastendrücke für das seit dem letzten Aufruf gewünschte Löschen (vor `begin_frame`
/// aufrufen und an die egui-Eingabe anhängen)
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
+382
View File
@@ -0,0 +1,382 @@
//! Pen input independent of the platform (Issue #44).
//!
//! On the desktop the pen comes from octotablet (Wayland/X11 tablet protocols, Windows Ink),
//! on Android as touch samples with the tool type stylus or eraser, which the patched winit
//! hands over separately (`winit::platform::android::take_pen_samples`). Both are turned into
//! the same [`PenInput`] events, which the app handles in one place.
use std::time::{Duration, Instant};
/// What happened to the pen.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PenEvent {
/// Pen comes into range (hovers above the screen)
In,
/// Pen left the range
Out,
/// Tip touches the screen (at the position of the last `Pose`)
Down,
/// Tip lifted
Up,
/// Position in logical pixels; pressure 0..=1 if known
Pose {
position: [f32; 2],
pressure: Option<f32>,
},
/// Pen button pressed or released; `key` identifies the button across sessions
Button { key: u64, pressed: bool },
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PenInput {
/// Identifies the pen (several pens can be in use on the desktop)
pub tool: u64,
/// Eraser end of the pen
pub is_eraser: bool,
pub event: PenEvent,
}
fn hash_of(value: impl std::hash::Hash) -> u64 {
use std::hash::Hasher;
// DefaultHasher::new() uses fixed keys: same input, same value in every session
let mut hasher = std::collections::hash_map::DefaultHasher::new();
value.hash(&mut hasher);
hasher.finish()
}
/// Turns octotablet's events into [`PenInput`]s, with each `Down` after the `Pose` of the
/// same frame (see [`crate::platform::down_after_pose`]).
#[cfg(not(target_os = "android"))]
pub fn from_octotablet<'a>(
events: impl IntoIterator<Item = octotablet::events::Event<'a>>,
) -> Vec<PenInput> {
use octotablet::events::{Event, ToolEvent};
let inputs = events.into_iter().filter_map(|event| {
let Event::Tool { tool, event } = event else {
return None;
};
let event = match event {
ToolEvent::In { .. } => PenEvent::In,
ToolEvent::Out | ToolEvent::Removed => PenEvent::Out,
ToolEvent::Down => PenEvent::Down,
ToolEvent::Up => PenEvent::Up,
ToolEvent::Pose(pose) => PenEvent::Pose {
position: pose.position,
pressure: pose.pressure.get(),
},
ToolEvent::Button { button_id, pressed } => PenEvent::Button {
key: hash_of(button_id),
pressed,
},
_ => return None,
};
Some(PenInput {
tool: hash_of(tool.id()),
is_eraser: tool.tool_type == Some(octotablet::tool::Type::Eraser),
event,
})
});
crate::platform::down_after_pose(
inputs,
|i| i.event == PenEvent::Down,
|i| matches!(i.event, PenEvent::Pose { .. }),
)
}
/// What an Android pen sample reports (mirrors `winit::platform::android::PenAction`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SampleAction {
HoverEnter,
HoverMove,
HoverExit,
Down,
Move,
Up,
Cancel,
}
/// One Android pen sample (mirrors `winit::platform::android::PenSample`).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Sample {
pub action: SampleAction,
/// Physical pixels
pub x: f32,
pub y: f32,
pub pressure: f32,
pub eraser: bool,
/// `MotionEvent.getButtonState()`
pub buttons: u32,
}
#[cfg(target_os = "android")]
impl From<winit::platform::android::PenSample> for Sample {
fn from(s: winit::platform::android::PenSample) -> Self {
use winit::platform::android::PenAction as A;
Self {
action: match s.action {
A::HoverEnter => SampleAction::HoverEnter,
A::HoverMove => SampleAction::HoverMove,
A::HoverExit => SampleAction::HoverExit,
A::Down => SampleAction::Down,
A::Move => SampleAction::Move,
A::Up => SampleAction::Up,
A::Cancel => SampleAction::Cancel,
},
x: s.x,
y: s.y,
pressure: s.pressure,
eraser: s.eraser,
buttons: s.buttons,
}
}
}
/// The two pen buttons of Android's `MotionEvent` (`BUTTON_STYLUS_PRIMARY`/`SECONDARY`).
const ANDROID_BUTTONS: [(u32, &str); 2] = [
(0x20, "android-stylus-primary"),
(0x40, "android-stylus-secondary"),
];
/// How long the pen may be out of sight before it counts as gone. Android reports
/// `HoverExit` right before the tip touches down, and pens without hover report nothing
/// after lifting.
const ANDROID_OUT_DELAY: Duration = Duration::from_millis(150);
/// Turns Android pen samples into [`PenInput`]s.
#[derive(Debug, Default)]
pub struct AndroidPen {
in_range: bool,
down: bool,
eraser: bool,
buttons: u32,
/// Since when the pen is neither hovering nor touching
gone_since: Option<Instant>,
}
impl AndroidPen {
/// The single pen Android reports
const TOOL: u64 = 1;
fn push(&self, out: &mut Vec<PenInput>, event: PenEvent) {
out.push(PenInput {
tool: Self::TOOL,
is_eraser: self.eraser,
event,
});
}
/// Events for `samples` (in this order); `scale` converts physical to logical pixels.
pub fn convert(&mut self, samples: &[Sample], scale: f32, now: Instant) -> Vec<PenInput> {
let mut out = Vec::new();
for sample in samples {
self.eraser = sample.eraser;
for (mask, name) in ANDROID_BUTTONS {
let pressed = sample.buttons & mask != 0;
if pressed != (self.buttons & mask != 0) {
self.push(
&mut out,
PenEvent::Button {
key: hash_of(name),
pressed,
},
);
}
}
self.buttons = sample.buttons;
let position = [sample.x / scale, sample.y / scale];
let touching = Some(sample.pressure.clamp(0.0, 1.0));
match sample.action {
SampleAction::HoverEnter | SampleAction::HoverMove => {
self.gone_since = None;
self.enter(&mut out);
let pose = PenEvent::Pose {
position,
pressure: None,
};
self.push(&mut out, pose);
}
SampleAction::HoverExit => {
if !self.down {
self.gone_since.get_or_insert(now);
}
}
SampleAction::Down => {
self.gone_since = None;
self.enter(&mut out);
let pose = PenEvent::Pose {
position,
pressure: touching,
};
self.push(&mut out, pose);
if !self.down {
self.down = true;
self.push(&mut out, PenEvent::Down);
}
}
SampleAction::Move => {
self.gone_since = None;
self.enter(&mut out);
let pressure = if self.down { touching } else { None };
self.push(&mut out, PenEvent::Pose { position, pressure });
}
SampleAction::Up | SampleAction::Cancel => {
if self.down {
if sample.action == SampleAction::Up {
let pose = PenEvent::Pose {
position,
pressure: touching,
};
self.push(&mut out, pose);
}
self.down = false;
self.push(&mut out, PenEvent::Up);
}
self.gone_since = Some(now);
}
}
}
out.extend(self.poll(now));
out
}
fn enter(&mut self, out: &mut Vec<PenInput>) {
if !self.in_range {
self.in_range = true;
self.push(out, PenEvent::In);
}
}
/// `Out` once the pen has been gone for a moment; call regularly while it is in range.
pub fn poll(&mut self, now: Instant) -> Vec<PenInput> {
let mut out = Vec::new();
let gone = self
.gone_since
.is_some_and(|since| now.duration_since(since) >= ANDROID_OUT_DELAY);
if gone && self.in_range && !self.down {
self.in_range = false;
self.gone_since = None;
if self.buttons != 0 {
for (mask, name) in ANDROID_BUTTONS {
if self.buttons & mask != 0 {
self.push(
&mut out,
PenEvent::Button {
key: hash_of(name),
pressed: false,
},
);
}
}
self.buttons = 0;
}
self.push(&mut out, PenEvent::Out);
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(action: SampleAction, x: f32, pressure: f32, buttons: u32) -> Sample {
Sample {
action,
x,
y: 10.0,
pressure,
eraser: false,
buttons,
}
}
fn kinds(inputs: &[PenInput]) -> Vec<&'static str> {
inputs
.iter()
.map(|i| match i.event {
PenEvent::In => "in",
PenEvent::Out => "out",
PenEvent::Down => "down",
PenEvent::Up => "up",
PenEvent::Pose { pressure: None, .. } => "hover",
PenEvent::Pose { .. } => "pose",
PenEvent::Button { pressed: true, .. } => "button-down",
PenEvent::Button { .. } => "button-up",
})
.collect()
}
#[test]
fn test_android_stroke_with_hover() {
use SampleAction::*;
let mut pen = AndroidPen::default();
let t = Instant::now();
let ms = |n| t + Duration::from_millis(n);
let out = pen.convert(&[sample(HoverEnter, 20.0, 0.0, 0)], 2.0, t);
assert_eq!(kinds(&out), ["in", "hover"]);
assert_eq!(
out[1].event,
PenEvent::Pose {
position: [10.0, 5.0],
pressure: None
}
);
// Android ends the hover right before the tip touches: no Out in between
let out = pen.convert(&[sample(HoverExit, 20.0, 0.0, 0)], 2.0, ms(5));
assert!(out.is_empty());
let out = pen.convert(
&[sample(Down, 20.0, 0.4, 0), sample(Move, 30.0, 0.5, 0)],
2.0,
ms(10),
);
assert_eq!(kinds(&out), ["pose", "down", "pose"]);
assert!(pen.poll(ms(500)).is_empty(), "still touching");
let out = pen.convert(&[sample(Up, 30.0, 0.0, 0)], 2.0, ms(600));
assert_eq!(kinds(&out), ["pose", "up"]);
let out = pen.convert(&[sample(HoverEnter, 30.0, 0.0, 0)], 2.0, ms(610));
assert_eq!(kinds(&out), ["hover"]);
pen.convert(&[sample(HoverExit, 30.0, 0.0, 0)], 2.0, ms(700));
assert!(pen.poll(ms(750)).is_empty());
assert_eq!(kinds(&pen.poll(ms(900))), ["out"]);
}
#[test]
fn test_android_pen_without_hover_leaves_after_lifting() {
use SampleAction::*;
let mut pen = AndroidPen::default();
let t = Instant::now();
let out = pen.convert(&[sample(Down, 0.0, 0.7, 0)], 1.0, t);
assert_eq!(kinds(&out), ["in", "pose", "down"]);
pen.convert(&[sample(Up, 0.0, 0.0, 0)], 1.0, t);
assert_eq!(kinds(&pen.poll(t + Duration::from_millis(200))), ["out"]);
}
#[test]
fn test_android_buttons_and_cancel() {
use SampleAction::*;
let mut pen = AndroidPen::default();
let t = Instant::now();
let out = pen.convert(
&[
sample(HoverMove, 0.0, 0.0, 0x20),
sample(Down, 0.0, 0.5, 0x20),
sample(Cancel, 0.0, 0.0, 0x20),
],
1.0,
t,
);
assert_eq!(
kinds(&out),
["button-down", "in", "hover", "pose", "down", "up"]
);
// Pen gone with the button still held: released before Out
let out = pen.poll(t + Duration::from_millis(200));
assert_eq!(kinds(&out), ["button-up", "out"]);
}
}
+200
View File
@@ -82,6 +82,206 @@ pub fn desktop_event_loop() -> Result<winit::event_loop::EventLoop<()>, winit::e
builder.build()
}
/// Waits until the window can be drawn to. On Android the native window only exists after
/// `Event::Resumed` (Issue #42); on the desktop it is there right away.
pub fn wait_until_resumed(
#[allow(unused_variables)] event_loop: &mut winit::event_loop::EventLoop<()>,
) {
#[cfg(target_os = "android")]
{
use winit::event::Event;
use winit::platform::pump_events::{EventLoopExtPumpEvents, PumpStatus};
let mut resumed = false;
while !resumed {
let status = event_loop.pump_events(None, |event, _| {
if matches!(event, Event::Resumed) {
resumed = true;
}
});
if let PumpStatus::Exit(_) = status {
std::process::exit(0);
}
}
}
}
/// Runs the event loop until the app quits. On Android the loop was already started by
/// [`wait_until_resumed`], so it is driven with `pump_events` (`run` would refuse to start).
pub fn run_event_loop<F>(
#[allow(unused_mut)] mut event_loop: winit::event_loop::EventLoop<()>,
#[allow(unused_mut)] mut handler: F,
) -> Result<(), winit::error::EventLoopError>
where
F: FnMut(winit::event::Event<()>, &winit::event_loop::EventLoopWindowTarget<()>),
{
#[cfg(target_os = "android")]
{
use winit::platform::pump_events::{EventLoopExtPumpEvents, PumpStatus};
loop {
if let PumpStatus::Exit(_) = event_loop.pump_events(None, &mut handler) {
return Ok(());
}
}
}
#[cfg(not(target_os = "android"))]
event_loop.run(handler)
}
/// The Android activity, for calls winit doesn't offer (soft keyboard, Issue #42).
#[cfg(target_os = "android")]
static ANDROID_APP: std::sync::OnceLock<winit::platform::android::activity::AndroidApp> =
std::sync::OnceLock::new();
/// Remembers the activity; called once by `android_main`.
#[cfg(target_os = "android")]
pub fn set_android_app(app: winit::platform::android::activity::AndroidApp) {
let _ = ANDROID_APP.set(app);
}
#[cfg(target_os = "android")]
pub fn android_app() -> Option<winit::platform::android::activity::AndroidApp> {
ANDROID_APP.get().cloned()
}
/// Window edges (left, top, right, bottom; physical pixels) covered by system bars or display
/// cutouts. Android: the app draws below the status and navigation bars, so its buttons must
/// keep out of them (Issue #50). Desktop: none.
pub fn safe_area_insets() -> [f32; 4] {
#[cfg(target_os = "android")]
{
crate::android_bridge::insets()
}
#[cfg(not(target_os = "android"))]
{
[0.0; 4]
}
}
/// Makes an exported file available to the user. Desktop: it already is (the path is
/// returned). Android: copied to `Download/ink-notes` through the MediaStore (Android 10 and
/// newer), as other apps can't see the app's own folder; older versions keep it there.
pub fn publish_export(path: std::path::PathBuf) -> Result<std::path::PathBuf, String> {
#[cfg(target_os = "android")]
match android_files::publish_to_downloads(&path) {
Ok(location) => {
let _ = std::fs::remove_file(&path);
return Ok(location);
}
Err(err) => log::warn!("export not copied to Downloads: {err}"),
}
Ok(path)
}
#[cfg(target_os = "android")]
mod android_files {
use jni::objects::{JObject, JValue};
use jni::JNIEnv;
use std::path::{Path, PathBuf};
/// Folder below the shared storage the exports go to
const RELATIVE_PATH: &str = "Download/ink-notes";
fn mime_type(path: &Path) -> &'static str {
match path.extension().and_then(|e| e.to_str()) {
Some("pdf") => "application/pdf",
Some("png") => "image/png",
Some("svg") => "image/svg+xml",
_ => "application/octet-stream",
}
}
pub fn publish_to_downloads(path: &Path) -> Result<PathBuf, String> {
let app = super::android_app().ok_or("no activity")?;
let name = path
.file_name()
.and_then(|n| n.to_str())
.ok_or("invalid file name")?
.to_string();
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
// Safety: pointers to the VM and the activity (a global reference) that live as long
// as the app
let vm =
unsafe { jni::JavaVM::from_raw(app.vm_as_ptr().cast()) }.map_err(|e| e.to_string())?;
let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?;
let activity = unsafe { JObject::from_raw(app.activity_as_ptr().cast()) };
let result = insert(&mut env, &activity, &name, mime_type(path), &bytes);
if env.exception_check().unwrap_or(false) {
let _ = env.exception_describe();
let _ = env.exception_clear();
}
result.map_err(|e| e.to_string())?;
Ok(Path::new(RELATIVE_PATH).join(name))
}
fn insert(
env: &mut JNIEnv,
activity: &JObject,
name: &str,
mime: &str,
bytes: &[u8],
) -> jni::errors::Result<()> {
let sdk = env
.get_static_field("android/os/Build$VERSION", "SDK_INT", "I")?
.i()?;
if sdk < 29 {
return Err(jni::errors::Error::JniCall(jni::errors::JniError::Unknown));
}
let values = env.new_object("android/content/ContentValues", "()V", &[])?;
for (key, value) in [
("_display_name", name),
("mime_type", mime),
("relative_path", RELATIVE_PATH),
] {
let key = env.new_string(key)?;
let value = env.new_string(value)?;
env.call_method(
&values,
"put",
"(Ljava/lang/String;Ljava/lang/String;)V",
&[JValue::Object(&key), JValue::Object(&value)],
)?;
}
let resolver = env
.call_method(
activity,
"getContentResolver",
"()Landroid/content/ContentResolver;",
&[],
)?
.l()?;
let collection = env
.get_static_field(
"android/provider/MediaStore$Downloads",
"EXTERNAL_CONTENT_URI",
"Landroid/net/Uri;",
)?
.l()?;
let uri = env
.call_method(
&resolver,
"insert",
"(Landroid/net/Uri;Landroid/content/ContentValues;)Landroid/net/Uri;",
&[JValue::Object(&collection), JValue::Object(&values)],
)?
.l()?;
if uri.is_null() {
return Err(jni::errors::Error::NullPtr("MediaStore insert"));
}
let stream = env
.call_method(
&resolver,
"openOutputStream",
"(Landroid/net/Uri;)Ljava/io/OutputStream;",
&[JValue::Object(&uri)],
)?
.l()?;
let array = env.byte_array_from_slice(bytes)?;
env.call_method(&stream, "write", "([B)V", &[JValue::Object(&array)])?;
env.call_method(&stream, "close", "()V", &[])?;
Ok(())
}
}
/// Gives the window the app id (Wayland: app_id, X11: WM_CLASS), so that dock and task bar
/// show the app icon for it.
pub fn with_app_id(
+4
View File
@@ -232,6 +232,8 @@ pub struct UiSettings {
/// Folder of the notebooks, e.g. inside a Nextcloud folder; default location if unset
/// (Issue #49)
pub storage_dir: Option<PathBuf>,
/// Compute handwritten math after `=` (Issue #46)
pub math_assist: bool,
}
impl Default for UiSettings {
@@ -253,6 +255,7 @@ impl Default for UiSettings {
language: None,
eraser_radius_mm: 3.0,
storage_dir: None,
math_assist: false,
}
}
}
@@ -350,6 +353,7 @@ mod tests {
language: Some(Language::English),
eraser_radius_mm: 6.0,
storage_dir: Some(PathBuf::from("/home/pia/Nextcloud/ink-notes")),
math_assist: true,
};
settings.save_to(&path).unwrap();
assert_eq!(UiSettings::load_from(&path), settings);
+257 -5
View File
@@ -317,6 +317,8 @@ pub enum UiAction {
UnlockCorruptedPage(u64),
/// Werkzeugleiste an die andere Fensterseite verlegen (wird gespeichert)
SetSidebarSide(SidebarSide),
/// Compute handwritten math after `=` on or off (Issue #46)
SetMathAssist(bool),
/// Sprache der Oberfläche; `None` = Sprache des Systems
SetLanguage(Option<crate::i18n::Language>),
/// Papierfarbe der Anzeige und Aufhellen dunkler Tinte (wird gespeichert)
@@ -341,6 +343,15 @@ pub enum UiAction {
ImportFile(std::path::PathBuf),
/// Store the notebooks in this folder, `None` for the default location (Issue #49)
SetStorageDir(Option<std::path::PathBuf>),
/// Sign in to a Nextcloud server through the browser; with the fingerprint of a
/// certificate the user trusts (Issue #49, stage 2)
CloudConnect {
server: String,
trusted: Option<String>,
},
CloudCancel,
CloudSyncNow,
CloudDisconnect,
/// Aktuelle Seite als Bild oder SVG exportieren
ExportPage(PageExportKind),
/// Titel einer Seite setzen (leer = entfernen)
@@ -413,6 +424,8 @@ pub struct UiState {
pub update_modal_rect: Option<egui::Rect>,
/// Steuert, ob Touch-Eingaben mit einem Finger zeichnen oder das Dokument verschieben (Default: false = reine Gesten-Navigation & Zoom)
pub touch_draw_enabled: bool,
/// Computes handwritten math after an `=` (Issue #46)
pub math: crate::math_assist::MathAssist,
/// Zuletzt gelöschtes Notizbuch für Rückgängig während der Sitzung (Papierkorb-Pfad, Titel)
pub last_deleted_notebook: Option<(std::path::PathBuf, String)>,
/// Stifte der Werkzeugleiste (Farbe und Stärke) und der gewählte davon; `pen_color` und
@@ -493,8 +506,9 @@ pub struct UiState {
pub scrollbar_grab: Option<f32>,
/// Aktuelle Abbildung Seite ↔ Bildschirm (für Lineal und Treffertests)
pub page_view: Option<PageView>,
/// Fenstergröße in logischen Punkten (vom letzten Bild)
pub screen_size: egui::Vec2,
/// Usable part of the window in logical points (from the last frame; on Android without the
/// system bars)
pub screen_rect: egui::Rect,
/// Eingabefeld „Titel dieser Seite“ in der Seitenübersicht und die Seite, zu der es gehört
pub page_title_input: String,
pub page_title_for: Option<u64>,
@@ -508,6 +522,9 @@ pub struct UiState {
/// Folder holding the notebooks and whether the user picked it (Issue #49)
pub storage_dir: std::path::PathBuf,
pub storage_dir_custom: bool,
/// Nextcloud sync as the app last saw it, and the server address being typed
pub cloud: crate::cloud_sync::View,
pub cloud_server_input: String,
}
impl Default for UiState {
@@ -546,6 +563,7 @@ impl Default for UiState {
show_updater_config: false,
update_modal_rect: None,
touch_draw_enabled: false,
math: crate::math_assist::MathAssist::default(),
last_deleted_notebook: None,
pen_presets: DEFAULT_PEN_PRESETS,
active_pen: 0,
@@ -588,13 +606,15 @@ impl Default for UiState {
ruler_drag: None,
scrollbar_grab: None,
page_view: None,
screen_size: egui::Vec2::ZERO,
screen_rect: egui::Rect::ZERO,
page_title_input: String::new(),
page_title_for: None,
page_picker_list: false,
last_picker_dir: None,
storage_dir: std::path::PathBuf::new(),
storage_dir_custom: false,
cloud: crate::cloud_sync::View::default(),
cloud_server_input: String::new(),
}
}
}
@@ -661,7 +681,7 @@ impl UiState {
/// Mitte des Bereichs neben der Werkzeugleiste (logische Punkte).
fn visible_canvas_center(&self) -> egui::Pos2 {
(self.screen_size / 2.0).to_pos2()
self.screen_rect.center()
+ egui::vec2(
match self.sidebar_side {
SidebarSide::Left => self.sidebar_reserved_width() / 2.0,
@@ -690,6 +710,11 @@ impl UiState {
fn open_file_picker(&mut self, kind: PickerKind) {
let dir = self.last_picker_dir.clone();
self.close_overlays();
// Android: the app can't look into other apps' folders; the system dialog can
#[cfg(target_os = "android")]
if crate::android_bridge::pick_file(kind) {
return;
}
self.file_picker = Some(FilePicker::open(kind, dir.as_deref()));
}
@@ -1647,6 +1672,57 @@ fn text_style_buttons(ui: &mut egui::Ui, btn: f32, state: &mut UiState) {
/// Bearbeitung eines Textfelds: egui-Textfeld an der Stelle und in der Größe des Felds auf
/// dem Blatt (ungedreht), mit derselben Schrift. Esc beendet die Bearbeitung.
/// The result offered next to a handwritten `=` (Issue #46): what was read and the result,
/// e.g. `2⁴ = 16`. Not interactive for egui, so that fingers and palms pass through to the
/// page; the pen and the mouse take it via [`crate::math_assist::MathAssist::take_hit`].
fn math_suggestion(ctx: &egui::Context, state: &mut UiState, notebook: &Notebook) {
state.math.chip_rect = None;
if state.view_mode {
return;
}
let (Some(view), Some(suggestion)) = (
state.page_view,
state.math.suggestion_for(notebook.current_page()).cloned(),
) else {
return;
};
let size = (suggestion.symbol_height * view.scale * 0.8).clamp(14.0, 48.0);
let response = egui::Area::new(egui::Id::new("math_suggestion"))
.fixed_pos(view.to_screen(suggestion.anchor))
.pivot(egui::Align2::LEFT_CENTER)
.order(egui::Order::Foreground)
.interactable(false)
.show(ctx, |ui| {
egui::Frame::none()
.fill(egui::Color32::from_rgba_unmultiplied(30, 34, 44, 235))
.stroke(egui::Stroke::new(
1.5_f32,
egui::Color32::from_rgb(70, 130, 230),
))
.rounding(size * 0.4)
.inner_margin(egui::vec2(size * 0.45, size * 0.2))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = size * 0.25;
ui.label(
egui::RichText::new(format!("{} =", suggestion.expression))
.size(size * 0.7)
.color(egui::Color32::from_gray(170)),
);
ui.label(
egui::RichText::new(&suggestion.result)
.size(size)
.strong()
.color(egui::Color32::WHITE),
);
});
});
})
.response;
// A little larger than drawn, as the pen does not hit exactly
state.math.chip_rect = Some(response.rect.expand(6.0));
}
fn text_edit_overlay(ctx: &egui::Context, state: &mut UiState) {
let adapt = state.adapt_ink_colors && state.paper_theme.is_dark();
let (Some(view), Some(edit)) = (state.page_view, state.text_edit.as_mut()) else {
@@ -3128,6 +3204,171 @@ pub fn key_allowed_in_view_mode(key: &winit::keyboard::Key, ctrl: bool) -> bool
}
}
/// "How long ago" for the last sync.
fn elapsed_label(at: std::time::SystemTime) -> String {
let secs = at.elapsed().map_or(0, |d| d.as_secs());
if secs < 60 {
t!("gerade eben").to_string()
} else if secs < 3600 {
t!("vor {minutes} min", minutes = secs / 60)
} else {
t!("vor {hours} h", hours = secs / 3600)
}
}
/// Nextcloud sync in the menu (Issue #49, stage 2).
fn cloud_section(ui: &mut egui::Ui, state: &mut UiState, actions: &mut Vec<UiAction>) {
use crate::cloud_sync::Status;
let small = |text: String, color: egui::Color32| {
egui::Label::new(egui::RichText::new(text).size(11.5).color(color)).wrap(true)
};
let grey = egui::Color32::from_gray(190);
let warn = egui::Color32::from_rgb(240, 180, 80);
let button = |text: String| egui::Button::new(text).min_size(egui::vec2(115.0, 28.0));
ui.add_space(4.0);
ui.label(
egui::RichText::new(t!("NEXTCLOUD"))
.size(10.5)
.strong()
.color(egui::Color32::from_gray(160)),
);
let status = state.cloud.status.clone();
if matches!(
status,
Status::Connecting | Status::WaitingForLogin { .. } | Status::Syncing
) {
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(500));
}
match status {
Status::Off | Status::Failed(_) if state.cloud.account.is_none() => {
if let Status::Failed(msg) = &status {
ui.add(small(format!("⚠ {msg}"), warn));
}
let field = ui.add(
egui::TextEdit::singleline(&mut state.cloud_server_input)
.hint_text(t!("z. B. cloud.example.org"))
.margin(egui::vec2(8.0, 6.0))
.desired_width(f32::INFINITY),
);
if state.cloud_server_input.trim().starts_with("http://") {
ui.add(small(
t!("⚠ Ohne https ist das App-Passwort im Netz lesbar – nur im eigenen Heimnetz verwenden").to_string(),
warn,
));
}
let enter = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
if ui
.add_enabled(
!state.cloud_server_input.trim().is_empty(),
button(t!("🔗 Verbinden …").to_string()),
)
.on_hover_text(t!("Anmeldung im Browser; die App bekommt ein eigenes App-Passwort, das sich in Nextcloud jederzeit widerrufen lässt"))
.clicked()
|| enter && !state.cloud_server_input.trim().is_empty()
{
actions.push(UiAction::CloudConnect {
server: state.cloud_server_input.clone(),
trusted: None,
});
}
}
Status::Off | Status::Connecting => {
ui.add(small(t!("⏳ Verbinde …").to_string(), grey));
if ui.add(button(t!("Abbrechen").to_string())).clicked() {
actions.push(UiAction::CloudCancel);
}
}
Status::Untrusted {
server,
fingerprint,
} => {
ui.add(small(
t!("⚠ Das Zertifikat von {server} ist von keiner bekannten Stelle signiert. Nur vertrauen, wenn dieser Fingerabdruck mit dem des Servers übereinstimmt:", server = server),
warn,
));
ui.add(
egui::Label::new(
egui::RichText::new(&fingerprint)
.monospace()
.size(10.0)
.color(grey),
)
.wrap(true),
);
ui.horizontal(|ui| {
if ui.add(button(t!("Vertrauen").to_string())).clicked() {
actions.push(UiAction::CloudConnect {
server: server.clone(),
trusted: Some(fingerprint.clone()),
});
}
if ui.add(button(t!("Abbrechen").to_string())).clicked() {
actions.push(UiAction::CloudCancel);
}
});
}
Status::WaitingForLogin { url } => {
ui.add(small(
t!("Bitte im Browser anmelden und den Zugriff erlauben …").to_string(),
grey,
));
ui.horizontal(|ui| {
if ui
.add(button(t!("🌐 Browser öffnen").to_string()))
.clicked()
{
actions.push(UiAction::OpenUrlInBrowser(url.clone()));
}
if ui.add(button(t!("Abbrechen").to_string())).clicked() {
actions.push(UiAction::CloudCancel);
}
});
}
Status::Idle { .. } | Status::Syncing | Status::Offline(_) | Status::Failed(_) => {
if let Some(account) = &state.cloud.account {
ui.add(small(format!("👤 {account}"), grey).truncate(true))
.on_hover_text(account);
}
let (text, color) = match &status {
Status::Idle { last: Some(at) } => (
t!("✔ Synchronisiert {when}", when = elapsed_label(*at)),
egui::Color32::from_rgb(120, 200, 120),
),
Status::Offline(msg) => (
t!("⚪ Offline – neuer Versuch in Kürze ({msg})", msg = msg),
grey,
),
Status::Failed(msg) => (format!("⚠ {msg}"), warn),
_ => (t!("🔄 Synchronisiere …").to_string(), grey),
};
ui.add(small(text, color));
if state.cloud.insecure {
ui.add(small(t!("⚠ Unverschlüsselt (http)").to_string(), warn));
}
ui.horizontal(|ui| {
if ui
.add_enabled(
!matches!(status, Status::Syncing),
button(t!("🔄 Jetzt").to_string()),
)
.on_hover_text(t!("Sofort mit dem Server abgleichen"))
.clicked()
{
actions.push(UiAction::CloudSyncNow);
}
if ui
.add(button(t!("Trennen").to_string()))
.on_hover_text(t!("Abgleich beenden und das App-Passwort widerrufen; die Notizbücher bleiben auf diesem Gerät und auf dem Server"))
.clicked()
{
actions.push(UiAction::CloudDisconnect);
}
});
}
}
}
/// Rendert die schwebende Toolbar und alle zugehörigen UI-Komponenten mit `egui`.
///
/// Gibt eine Liste von `UiAction`-Events zurück, die vom Hauptprogramm verarbeitet werden.
@@ -3139,12 +3380,13 @@ pub fn render_ui(
updater: &crate::updater::UpdaterController,
) -> Vec<UiAction> {
let mut actions = std::mem::take(&mut state.queued_actions);
state.screen_size = ctx.screen_rect().size();
state.screen_rect = ctx.screen_rect();
page_separators(ctx, state, notebook);
page_scrollbar(ctx, state, notebook, &mut actions);
space_preview(ctx, state, notebook);
ruler_overlay(ctx, state);
text_edit_overlay(ctx, state);
math_suggestion(ctx, state, notebook);
context_menu(ctx, state, &mut actions);
let mut pen_btn_rect = None;
@@ -4378,6 +4620,15 @@ pub fn render_ui(
.on_hover_text(
t!("Aus: ein Finger verschiebt, zwei Finger zoomen (empfohlen mit Stift).\nAn: ein Finger zeichnet."),
);
let mut math = state.math.enabled;
if ui
.checkbox(&mut math, t!("Formeln nach „=“ ausrechnen"))
.on_hover_text(t!("Nach einem handgeschriebenen „=“ erscheint das Ergebnis daneben, z. B. 2⁴ = 16. Antippen mit dem Stift oder Klicken fügt es als Text ein.\nGelesen werden Ziffern, + − · : /, Klammern, Hochzahlen, Brüche und x, y, a, b."))
.changed()
{
state.math.set_enabled(math);
actions.push(UiAction::SetMathAssist(math));
}
if let Some(curve) = pressure_curve_editor(ui, state) {
actions.push(UiAction::SavePressureCurve(curve));
}
@@ -4463,6 +4714,7 @@ pub fn render_ui(
actions.push(UiAction::SetStorageDir(None));
}
});
cloud_section(ui, state, &mut actions);
section(ui, t!("STATUS"));
let (dot_color, status_text) = autosave_status_style(autosave_status);
+35
View File
@@ -77,6 +77,41 @@ impl<T> EventLoopBuilderExtAndroid for EventLoopBuilder<T> {
/// depending on the `android_activity` crate, and instead consume the API that
/// is re-exported by Winit.
///
/// ink-notes patch: what a pen sample reports (Issue #44).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PenAction {
/// Pen comes near the screen, moves above it or leaves it
HoverEnter,
HoverMove,
HoverExit,
/// Pen touches the screen, moves on it, is lifted or the gesture is cancelled
Down,
Move,
Up,
Cancel,
}
/// ink-notes patch: one sample of a pen (tool type stylus or eraser). Such pointers are not
/// reported as `WindowEvent::Touch`; the app collects them with [`take_pen_samples`].
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PenSample {
pub action: PenAction,
/// Position in physical pixels
pub x: f32,
pub y: f32,
/// Usually 0..=1
pub pressure: f32,
/// Eraser end of the pen (tool type eraser)
pub eraser: bool,
/// `MotionEvent.getButtonState()`; the pen buttons are 0x20 (primary) and 0x40 (secondary)
pub buttons: u32,
}
/// ink-notes patch: the pen samples received since the last call, oldest first.
pub fn take_pen_samples() -> Vec<PenSample> {
crate::platform_impl::take_pen_samples()
}
/// For compatibility applications should then import the `AndroidApp` type for
/// their `android_main(app: AndroidApp)` function like:
/// ```rust
+68 -2
View File
@@ -11,7 +11,7 @@ use std::{
time::{Duration, Instant},
};
use android_activity::input::{InputEvent, KeyAction, Keycode, MotionAction};
use android_activity::input::{InputEvent, KeyAction, Keycode, MotionAction, ToolType};
use android_activity::{
AndroidApp, AndroidAppWaker, ConfigurationRef, InputStatus, MainEvent, Rect,
};
@@ -382,6 +382,7 @@ impl<T: 'static> EventLoop<T> {
InputEvent::MotionEvent(motion_event) => {
let window_id = window::WindowId(WindowId);
let device_id = event::DeviceId(DeviceId(motion_event.device_id()));
record_pen_samples(motion_event);
let phase = match motion_event.action() {
MotionAction::Down | MotionAction::PointerDown => {
@@ -408,6 +409,10 @@ impl<T: 'static> EventLoop<T> {
};
for pointer in pointers {
// ink-notes patch: pens are reported through `take_pen_samples`
if is_pen(&pointer) {
continue;
}
let location = PhysicalPosition {
x: pointer.x() as _,
y: pointer.y() as _,
@@ -450,6 +455,15 @@ impl<T: 'static> EventLoop<T> {
&mut self.combining_accent,
);
// ink-notes patch: the character typed, so that text fields receive it
let text = match (state, &key_char) {
(
event::ElementState::Pressed,
Some(android_activity::input::KeyMapChar::Unicode(c)),
) if !c.is_control() => Some(smol_str::SmolStr::from_iter([*c])),
_ => None,
};
let event = event::Event::WindowEvent {
window_id: window::WindowId(WindowId),
event: event::WindowEvent::KeyboardInput {
@@ -460,7 +474,7 @@ impl<T: 'static> EventLoop<T> {
logical_key: keycodes::to_logical(key_char, keycode),
location: keycodes::to_location(keycode),
repeat: key.repeat_count() > 0,
text: None,
text,
platform_specific: KeyEventExtra {},
},
is_synthetic: false,
@@ -1124,3 +1138,55 @@ impl VideoMode {
self.monitor.clone()
}
}
/// ink-notes patch: pen samples waiting for `take_pen_samples` (Issue #44)
static PEN_SAMPLES: Mutex<Vec<crate::platform::android::PenSample>> = Mutex::new(Vec::new());
fn is_pen(pointer: &android_activity::input::Pointer<'_>) -> bool {
matches!(pointer.tool_type(), ToolType::Stylus | ToolType::Eraser)
}
fn record_pen_samples(motion_event: &android_activity::input::MotionEvent<'_>) {
use crate::platform::android::{PenAction, PenSample};
let action = match motion_event.action() {
MotionAction::Down | MotionAction::PointerDown => PenAction::Down,
MotionAction::Up | MotionAction::PointerUp => PenAction::Up,
MotionAction::Move => PenAction::Move,
MotionAction::Cancel => PenAction::Cancel,
MotionAction::HoverEnter => PenAction::HoverEnter,
MotionAction::HoverMove => PenAction::HoverMove,
MotionAction::HoverExit => PenAction::HoverExit,
_ => return,
};
let buttons = motion_event.button_state().0;
let mut record = |pointer: android_activity::input::Pointer<'_>| {
if !is_pen(&pointer) {
return;
}
let sample = PenSample {
action,
x: pointer.x(),
y: pointer.y(),
pressure: pointer.pressure(),
eraser: pointer.tool_type() == ToolType::Eraser,
buttons,
};
let mut samples = PEN_SAMPLES.lock().unwrap_or_else(|e| e.into_inner());
// Nobody collects them (app without pen support): don't grow without bound
if samples.len() >= 4096 {
samples.remove(0);
}
samples.push(sample);
};
match action {
PenAction::Down | PenAction::Up => {
record(motion_event.pointer_at_index(motion_event.pointer_index()))
}
_ => motion_event.pointers().for_each(record),
}
}
pub fn take_pen_samples() -> Vec<crate::platform::android::PenSample> {
std::mem::take(&mut *PEN_SAMPLES.lock().unwrap_or_else(|e| e.into_inner()))
}